Re-cut of ../evidence/20260817-speedway-recon.md against the canon matrix,
plus a Lapses section. Citations are that recon's, which read the tree directly;
line references are to /Users/alcatraz627/Code/Versable/speedway/. Live Cloud
Run state read 2026-08-17.
1. What it is#
An app with its runner inside, not a module. TypeScript, React Router 7 SSR on
Express, deployed to Cloud Run in us-central1 as speedway and speedway-dev,
with Firestore as the primary datastore, GCS for uploaded files, Cloud Tasks for
dispatch, and Gemini via Vertex for the domain work. It also runs in project
versable-pim alongside pim-web-dev and pim-worker-dev.
Callers are humans in a browser, authenticated by session cookie. There is no machine-to-machine path in or out except the outbound extractor client.
The domain is PIM catalog enrichment: a job owns a catalog of parts, and four
payload modules (partType, scrape, normalize, content) each run over it.
2. Shape of the runner#
A stage pipeline over a job's catalog rather than a generic queue. A Job owns
a parts catalog and a runs subcollection; each Run is one execution of one
module over that catalog.
browser ──form action──▶ createRunIfIdle (transactional, refuses if inflight) │ ▼ queue.server.ts Cloud Tasks in prod setImmediate in dev │ ▼ POST /tasks/module + X-Tasks-Secret run.server.ts::processRun 58 lines, switch on run.module │ ┌──────────┬───────┴───────┬──────────┐ partType scrape normalize content └──────────┴───────┬───────┴──────────┘ │ each pages parts by normalizedSku, PAGE=500 │ stamps cursor + heartbeatAt every batch ▼ rollUpStage (transactional) → orchestrate() queues at most one next stage per evaluationTwo things ride the same machinery: a vendor extractor client for scrape
(async dispatch, webhook delivery, polling reconciliation) and a usage-metering
ledger deliberately decoupled from run outcome.
The orchestrator is a pure trigger-driven function, not a process. It fires on ingest, a run finishing, a review-queue quiet period, or explicit consent.
3. Concern by concern#
| # | Concern | Mechanism here | Cite | Vendor |
|---|---|---|---|---|
| 1 | Runner/payload seam | modules/run.server.ts + queue.server.ts vs modules/{partType,scrape,normalize,content} | modules/run.server.ts:7-58 | no |
| 2 | Caller identity | opaque session token in HttpOnly cookie vsbl_session, Firestore doc with expiresAt | auth/session.server.ts:37-163 | no |
| 3 | Tenancy | org (billing) owns workspaces; every path scoped workspaces/{wid}/… | auth/access.server.ts:8-65, scope.server.ts:10-38 | no |
| 4 | Roles / RBAC | member < admin < owner, hasRole/requireRole | auth/access.server.ts:67-76 | no |
| 5 | Submit surface | form action per module; two-phase create via setupHold | routes/workspaces/modules.tsx:227-343, jobs.server.ts:52-89 | no |
| 6 | Job state ownership | app owns; job doc plus runs subcollection | jobs.server.ts | no |
| 7 | Derived vs stored status | job status derived in a transaction from the stages map; run status stored | jobs.server.ts:245-311,282-284 | no |
| 8 | Dispatch / queue | Cloud Tasks HTTP task in prod, setImmediate in dev, gated on three env vars | queue.server.ts:69-108 | Cloud Tasks |
| 9 | Concurrency | one active run per job+module, enforced transactionally over the last 25 runs | runs.server.ts (createRunIfIdle) | no |
| 10 | Retry / backoff | stall recovery ×3 from cursor; no queue-level retry config in the tree | runs.server.ts:231-353 | no |
| 11 | Heartbeat | heartbeatAt per batch, STALL_MS 3 min, transactional requeue | runs.server.ts:231-353 | no |
| 12 | Cancel | cooperative cancelRequested checked between batches; finalizes immediately if already stalled | routes/workspaces/modules.tsx:360-438, runs.server.ts:199-229 | no |
| 13 | Resume / checkpoint | cursor on the run doc; requeueRunIfIdle resumes from it | runs.server.ts:90-104 | no |
| 14 | Idempotency | settleRunOnce first-writer-wins; intent doc before paid calls; per-event usage ledger | runs.server.ts:90-104, scrape.server.ts:456-475 | no |
| 15 | Storage of inputs | files to GCS workspaces/{wid}/{sourceId}/{filename}, resumable above 4MB; rows as Firestore docs | storage.server.ts:1-60 | GCS |
| 16 | Storage of outcomes | run doc plus chunked log subcollection plus denormalized stages on the job | scope.server.ts:13-38, jobs.server.ts:250-311 | Firestore |
| 17 | Results reporting | list views read the denormalized rollup, never scan runs | jobs.server.ts:250-311 | no |
| 18 | Logs per job and item | workspace events rail plus per-run chunked log, resumable across workers | events.server.ts, joblog.server.ts:1-131 | no |
| 19 | Tracing | none | ||
| 20 | Usage metering | idempotent per-event ledger, dead-letter plus replay, pinned to the original cycle | usage/usage.server.ts (833 lines) | no |
| 21 | Limits / quotas | checkLimits before work starts, soft/hard/overage modes | routes/workspaces/modules.tsx:259-321 | no |
| 22 | Caching | cross-workspace scrape cache: SERP in Firestore 7d, page text in GCS 1mo; in-process TTL microcache for the auth chain | per recon | Firestore, GCS |
| 23 | Rate limiting outbound | OXYLABS_CONCURRENCY and friends as env caps | config.server.ts:1-139 | no |
| 24 | Config | one flat object read once from process.env at load | config.server.ts:1-139 | no |
| 25 | Secrets | env | config.server.ts | no |
| 26 | Human in the loop | review queue; "review-quiet" is an orchestrator trigger | orchestrator.server.ts | no |
| 27 | Completion signalling | in-process chaining; extractor uses webhook plus reconciliation sweep | extractor-sweep.server.ts | no |
| 28 | Capability discovery | none | ||
| 29 | Versioning | /build-info reports commit, branch, kit, build time | routes/build-info.ts | no |
| 30 | Health / readiness | /livez; /healthz is edge-reserved on *.run.app | per recon | Cloud Run quirk |
| 31 | Provisioning | release.sh and deploy/deploy.sh, not interchangeable | deploy/deploy.sh, scripts/release.sh | gcloud |
| 32 | Local dev | in-process when Cloud Tasks vars unset | queue.server.ts:69-108 | no |
| 33 | Output delivery | exports built in-app | no | |
| 34 | Data retention | none | ||
| 35 | Multiple versions | n/a | ||
| 36 | Conformance | none | ||
| 37 | Outputs and exports | no output layer at all: an export is a stateless XLSX built live from parts at request time, and the only thing persisted is a ledger row saying a download happened. M differs from N by sheet, one row per part, per fitment application, per image URL capped at 120, or per (part, attribute); paged 200 at a time to a 10,000 cap with honest first-N-of-M headers. Readable mid-run with no lock or snapshot, so a part can export half normalized | app/lib/uploadtemplate.server.ts:606-703,712-765,328,368-401, app/lib/export.server.ts:19-38, ../evidence/20260818-data-model-split/speedway.md | no |
| 38 | Data ownership split | Firestore-document-shaped and job-siloed: a workspace holds shared files and named jobs, each job owns a private catalog of parts plus its own runs and review queue, so the same SKU in two jobs is two unrelated documents and there is no workspace-global catalog. Big scraped payloads spill to GCS behind a pointer. Taxonomy and export layout are baked into the image at build time, identical for every customer. Auth, loadsheets and the scrape caches sit top-level, outside the workspace | app/lib/parts.server.ts:53-63, ../evidence/20260818-data-model-split/speedway.md | Firestore + GCS |
Vendor coupling is broader than versable-runner's but shallower per site:
Firestore for all structured state, GCS for files, Cloud Tasks for dispatch,
Vertex for the domain work. The dispatch abstraction in queue.server.ts
already has a second backend, so that seam is provably portable.
4. Runner vs payload#
The seam is clean at the dispatch layer and convention-based below it.
Runner: jobs.server.ts, runs.server.ts, modules/run.server.ts,
queue.server.ts, orchestrator.server.ts, joblog.server.ts,
events.server.ts, scope.server.ts, usage/usage.server.ts, auth/*,
routes/tasks/module.tsx.
Payload: modules/{scrape,normalize,partType,content}.server.ts,
extractor.server.ts, gemini.server.ts, and the content/fitment/pcdb helpers.
modules/run.server.ts is 58 lines of pure switch-and-delegate and imports
nothing domain-specific. Payload modules talk back through a small consistent
verb set: getRun, updateRun, settleRunOnce, createJobLogger,
rollUpStage.
The named leak. It is not a plugin architecture with a formal interface.
Each payload module hand-implements its own checkpoint loop, its own
finishRun/markError/markCancelled trio, and its own metering calls. The
checkpoint/heartbeat/cancel/log pattern is copy-pasted nearly verbatim across
three of four modules (partType.server.ts:114-187,
normalize.server.ts:440-501, content.server.ts:628-733). That repetition is
runner logic living in payload files, and it is the single clearest argument in
the estate for canon row 13's ruling that checkpointing be a runner helper.
5. Deliberate decisions#
Status is derived, never stored as truth (jobs.server.ts:282-284).
Recomputed from the live stages map inside a transaction on every rollup, so a
stale writer cannot mark a finished pipeline running or the reverse. Same
conclusion as versable-runner, reached with a completely different datastore.
Idempotency is the load-bearing property everywhere, not just in metering.
Run completion (settleRunOnce, first writer wins), extractor dispatch (an
intent doc written before the paid call, so a crash mid-call leaves a
traceable hold rather than a silent re-pay, scrape.server.ts:456-475), and
metering (per-event ledger doc) all use the same write-intent, check-before-act,
terminal-guard shape. The recon notes these were arrived at independently.
Metering is decoupled from run outcome by design. checkLimits throws
before work starts; recordUsage never throws and dead-letters on failure with
the billing cycle pinned, so a Firestore hiccup during metering can never fail
or roll back real work, and a delayed replay still lands in the correct period.
Metering counts fields requested, not fields delivered
(scrape.server.ts:387-403), documented as a deliberate reversal of an earlier
per-delivery scheme.
The orchestrator models a DAG, not a chain (orchestrator.server.ts:47-52).
scrape and partType are explicit peers rather than a sequence, because
scrape's coverage never reaches 100% and would otherwise gate a stage unrelated
to it. The comments call this out as correcting an earlier assumption that order
implies dependency.
Cancellation is cooperative except where it cannot be. Scrape uniquely
issues a best-effort vendor-side cancel (extractor.server.ts:219-226) because
a cooperative flag cannot interrupt a hung outbound call.
6. Lapses#
Task callback auth is a static shared secret, not OIDC
(routes/tasks/module.tsx:4-9,21). Timing-safe compared, but a single long-lived
value with no rotation story and no per-caller identity. versable-runner
verifies a real OIDC token on the equivalent route, so the estate already
contains the better answer and this instance does not use it.
Caller identity (2) is hand-rolled and has no machine path. Password auth, own session store, no external IdP. Fine for browser users, but there is no way for another service to call speedway at all. Canon 2 is open partly because no instance solves this.
Checkpointing is copy-pasted convention, not a helper (row 13). Three near-verbatim implementations. Canon settles this as a runner helper; speedway is the evidence for why.
No tracing (19). Canon recommends Langfuse behind a no-op default. Speedway has no tracing at all, despite being the instance doing the most LLM work.
No queue-level retry configuration (10). Cloud Tasks re-delivery is absorbed by idempotent terminal-state checks rather than configured. That works, but the retry budget is implicit and nobody can state it. Canon 10 settles that the handler owns the budget, which speedway satisfies in effect while never naming the number.
No capability discovery (28), no conformance (36), no retention (34).
Two deploy scripts that are not interchangeable (release.sh versus
deploy/deploy.sh). Canon 31 recommends walmart's branch mapping; this is the
counter-example.
Worth recording as the opposite of a lapse: speedway is the only instance with limits and quotas (21), a human-in-the-loop review queue (26), and a dead-lettered replayable usage ledger (20). Canon already names its metering as the shape to copy.
7. Unproven#
Inherited from the recon, which flagged them:
extractor-results.server.tsandextractor-sweep.server.ts, the webhook claim path and reconciliation sweep, were referenced fromscrape.server.tsbut never opened. Row 27's mechanism is inferred from call sites and comments.usage/catalog.tswas imported but not read, so the metric list and plan shapes behind rows 20 and 21 are unconfirmed.gemini.server.tswas not read; retry and rate-limit behaviour on the Vertex side is unknown.- Cloud Tasks queue-level retry config is set nowhere in the repo, so it likely lives in Terraform or gcloud config outside the tree. Not confirmed, and this is why row 10 above says "no queue-level retry config in the tree" rather than "none".
orgs.server.tsandworkspaces.server.tswere referenced but not read in full, so rows 3 and 4 rest partly on call sites.ingest.server.tsandroutes/tasks/ingest.tsxwere located but not read. How sources first becomeparts, the very first pipeline stage, is inferred.
Added this pass:
- Rows 22, 27 and 30 are transcribed from the recon's prose and the canon matrix rather than from files I opened myself.
- The
versable-pimdeployment (pim-web-dev,pim-worker-dev, plus aspeedwayservice) was read from live Cloud Run state. How it relates to thespeedway-versabledeployment was not investigated, and the presence of a separatepim-worker-devsuggests a worker split that this doc does not describe. - Nothing was executed. No run was submitted.