Agent docs

speedway

Re-cut of ../evidence/20260817-speedway-recon.md against the canon matrix, plus a Lapses section.

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 evaluation

Two 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#

#ConcernMechanism hereCiteVendor
1Runner/payload seammodules/run.server.ts + queue.server.ts vs modules/{partType,scrape,normalize,content}modules/run.server.ts:7-58no
2Caller identityopaque session token in HttpOnly cookie vsbl_session, Firestore doc with expiresAtauth/session.server.ts:37-163no
3Tenancyorg (billing) owns workspaces; every path scoped workspaces/{wid}/…auth/access.server.ts:8-65, scope.server.ts:10-38no
4Roles / RBACmember < admin < owner, hasRole/requireRoleauth/access.server.ts:67-76no
5Submit surfaceform action per module; two-phase create via setupHoldroutes/workspaces/modules.tsx:227-343, jobs.server.ts:52-89no
6Job state ownershipapp owns; job doc plus runs subcollectionjobs.server.tsno
7Derived vs stored statusjob status derived in a transaction from the stages map; run status storedjobs.server.ts:245-311,282-284no
8Dispatch / queueCloud Tasks HTTP task in prod, setImmediate in dev, gated on three env varsqueue.server.ts:69-108Cloud Tasks
9Concurrencyone active run per job+module, enforced transactionally over the last 25 runsruns.server.ts (createRunIfIdle)no
10Retry / backoffstall recovery ×3 from cursor; no queue-level retry config in the treeruns.server.ts:231-353no
11HeartbeatheartbeatAt per batch, STALL_MS 3 min, transactional requeueruns.server.ts:231-353no
12Cancelcooperative cancelRequested checked between batches; finalizes immediately if already stalledroutes/workspaces/modules.tsx:360-438, runs.server.ts:199-229no
13Resume / checkpointcursor on the run doc; requeueRunIfIdle resumes from itruns.server.ts:90-104no
14IdempotencysettleRunOnce first-writer-wins; intent doc before paid calls; per-event usage ledgerruns.server.ts:90-104, scrape.server.ts:456-475no
15Storage of inputsfiles to GCS workspaces/{wid}/{sourceId}/{filename}, resumable above 4MB; rows as Firestore docsstorage.server.ts:1-60GCS
16Storage of outcomesrun doc plus chunked log subcollection plus denormalized stages on the jobscope.server.ts:13-38, jobs.server.ts:250-311Firestore
17Results reportinglist views read the denormalized rollup, never scan runsjobs.server.ts:250-311no
18Logs per job and itemworkspace events rail plus per-run chunked log, resumable across workersevents.server.ts, joblog.server.ts:1-131no
19Tracingnone
20Usage meteringidempotent per-event ledger, dead-letter plus replay, pinned to the original cycleusage/usage.server.ts (833 lines)no
21Limits / quotascheckLimits before work starts, soft/hard/overage modesroutes/workspaces/modules.tsx:259-321no
22Cachingcross-workspace scrape cache: SERP in Firestore 7d, page text in GCS 1mo; in-process TTL microcache for the auth chainper reconFirestore, GCS
23Rate limiting outboundOXYLABS_CONCURRENCY and friends as env capsconfig.server.ts:1-139no
24Configone flat object read once from process.env at loadconfig.server.ts:1-139no
25Secretsenvconfig.server.tsno
26Human in the loopreview queue; "review-quiet" is an orchestrator triggerorchestrator.server.tsno
27Completion signallingin-process chaining; extractor uses webhook plus reconciliation sweepextractor-sweep.server.tsno
28Capability discoverynone
29Versioning/build-info reports commit, branch, kit, build timeroutes/build-info.tsno
30Health / readiness/livez; /healthz is edge-reserved on *.run.appper reconCloud Run quirk
31Provisioningrelease.sh and deploy/deploy.sh, not interchangeabledeploy/deploy.sh, scripts/release.shgcloud
32Local devin-process when Cloud Tasks vars unsetqueue.server.ts:69-108no
33Output deliveryexports built in-appno
34Data retentionnone
35Multiple versionsn/a
36Conformancenone
37Outputs and exportsno 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 normalizedapp/lib/uploadtemplate.server.ts:606-703,712-765,328,368-401, app/lib/export.server.ts:19-38, ../evidence/20260818-data-model-split/speedway.mdno
38Data ownership splitFirestore-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 workspaceapp/lib/parts.server.ts:53-63, ../evidence/20260818-data-model-split/speedway.mdFirestore + 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.ts and extractor-sweep.server.ts, the webhook claim path and reconciliation sweep, were referenced from scrape.server.ts but never opened. Row 27's mechanism is inferred from call sites and comments.
  • usage/catalog.ts was imported but not read, so the metric list and plan shapes behind rows 20 and 21 are unconfirmed.
  • gemini.server.ts was 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.ts and workspaces.server.ts were referenced but not read in full, so rows 3 and 4 rest partly on call sites.
  • ingest.server.ts and routes/tasks/ingest.tsx were located but not read. How sources first become parts, 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-pim deployment (pim-web-dev, pim-worker-dev, plus a speedway service) was read from live Cloud Run state. How it relates to the speedway-versable deployment was not investigated, and the presence of a separate pim-worker-dev suggests a worker split that this doc does not describe.
  • Nothing was executed. No run was submitted.
@versable-git/ui · reference, canon, and method, read in place