Agent docs

The concerns, and where each one stands

> Evidence: 14 file:line witnesses as of 2026-08-18.

Evidence: 14 file:line witnesses as of 2026-08-18. Confidence: high, most rules here have a named witness. What changes it: the first module built against this doc (../PLAN.md forge-1) and its instance breakdown.

The map of everything a production module or app has to answer, with how each of the three instances in the matrix answers it today and where the contract lands. Five instances are broken down in ../instances/; the matrix compares three of them because those three were read first and are different enough to argue with each other. This is the index for canon/; each numbered doc below takes one row or a cluster of rows and states the rule with its reasoning.

Audience: anyone deciding what a new module has to cover, or checking whether an existing one is missing something.

How to read the matrix#

The three instances are different enough that agreement between them is evidence and disagreement is a real choice:

  • versable-runner (services-api, runner-service): Python, Cloud Run, Cloud Tasks, GCS as the only datastore. A pure module: no users, no UI.
  • speedway: TypeScript, React Router SSR on Cloud Run, Firestore, GCS, Cloud Tasks in prod and in-process in dev. An app with its runner inside.
  • walmart-mvp: Python FastAPI, Postgres on Cloud SQL, Redis + arq, GCS. An app with its runner inside, four Cloud Run services from one image (walmart-api, walmart-api-prod, walmart-worker, walmart-worker-prod, read from live state 2026-08-17): api and worker split by role, prod and non-prod split by environment.

Stance column: settled (all instances agree, or the disagreement has an obvious winner; the contract fixes it), recommended (the contract names a default and the trade-off; a module may deviate with a reason), open (nobody has built it well yet, or the instances split and the contract has to design forward). Evidence: ../evidence/ and ../instances/.

The matrix#

#Concernversable-runnerspeedwaywalmart-mvpStanceDoc
1Runner/payload seamapp/ vs lib/, 12/12 files runner; survived a forkmodules/run.server.ts + queue.server.ts vs modules/{partType,scrape,normalize,content}orchestrator.py, jobs.py, worker.py vs ingest/, taxonomy/, scraping/, content/, walmart/settled01
2Caller identityone shared password for all callers, X-API-Key or bearer; nothing verifies metasession cookie, hand-rolled password auth, no IdPbearer JWT HS256 for the SPA; no machine-to-machine schemeopen, design forward02
3Tenancynone; meta.{service,organization,user} is a self-declared tagorg (billing) → workspace; every path scoped workspaces/{wid}/…X-Org-Id header checked against membership; per-resource require_membershipsettled: tenant is mandatory on every job02
4Roles / RBACnonemember < admin < owner per orgowner/admin manage, member otherwiserecommended: module receives scopes, app owns roles02
5Submit surfacePOST /jobs {data, method, params, meta} and POST /jobs/run-file; 422 on unknown methodform action per module; two-phase create with setupHoldmultipart upload; synchronous dupe check in-requestrecommended: one job-submit shape, O(1) in items03, contracts/module-surface
6Job state ownershipmodule owns; caller pollsapp owns (job doc + runs subcollection)app owns (Job row)open: module-owned, caller-owned, or mirrored03
7Derived vs stored statusderived from blob listings; nothing to go stalejob status derived in a transaction from the stages map; run status storedstored at two altitudes: Job.status/stage/stage_state with one writer (evaluate_job), Part.status with six writersrecommended: derive where listing is cheap; else one mutation point per altitude03
8Dispatch / queueCloud Tasks, self-push, one fanout task then one task per itemCloud Tasks HTTP task in prod, setImmediate in dev, shared secret on callbackarq over Redis, deterministic job idsrecommended: queue behind a port with an in-process mode04
9Concurrencyqueue maxConcurrentDispatches and nothing elseone active run per job+module, transactionalarq max_jobs=2 after an OOMrecommended: name the semaphore; per-tenant fairness is app-side04
10Retry / backoffhandler owns 3 attempts, queue is a backstop at 5stall recovery ×3 from cursor; no queue retry confignone for the current pipeline (job_timeout=3600 only)settled: the handler owns the budget; the queue is a backstop04
11Heartbeat / crash recoverynone needed: a dead task is redelivered; result writes are guardedheartbeatAt per batch, STALL_MS 3 min, transactional requeuenone for the current pipeline; legacy pipeline has itsettled: any run longer than one dispatch needs a heartbeat04
12Cancelmarker blob + delete pending tasks; in-flight finishescooperative flag checked between batches; vendor cancel for scrapenone for the current pipelinesettled: cooperative cancel is mandatory; state after cancel is defined04
13Resume / checkpointper-item, so resume is "redeliver the item"cursor on the run doc, copy-pasted in three payload filesupsert-idempotent re-run, no cursorrecommended: checkpoint as a runner helper, not payload convention04
14Idempotencydeterministic task names, if_generation_match=0 on writessettleRunOnce first writer wins; intent doc before paid calls; per-event usage ledgerdeterministic arq ids; part_error_id; publish dedup windowsettled: idempotency is structural, at every write that costs money or ends a run04
15Storage of inputspayload.json + items/{idx}.json in GCSfiles in GCS, rows in Firestorefiles in GCS, JobFile rows in Postgresrecommended: bytes in object storage, keyed by tenant/job05
16Storage of outcomesresults/, errors/, usage/ per item in GCSrun doc + chunked log subcollection + denormalized stages on the jobPart row per item, PartError per (part, field)recommended: per-item outcomes addressable by index, one place05
17Results reportingGET /jobs/{job_id}/results?offset&limit, meta filters, /errors, /statslist views read the denormalized rolluproutes per resourcesettled: pagination + filter on every list; per-item fetch03, contracts/module-surface
18Logs, per job and per itemusage/{idx}.json sidecar; no per-item log streamworkspace events + per-run chunked log, resumable across workersstage log capped at 20 lines, latest-run-only; stage summaries JSONopen: the per-item log a caller can read is the missing piece07
19TracingLangfuse via lib/ (observe on the Gemini provider), keys default empty; the payload owns the tracing decision, a seam leaknoneLangfuse, optional, per processrecommended: Langfuse behind a no-op default, wrapper provided by the runner07
20Usage meteringduration, attempts, tokens, LLM cost per item and per modelidempotent per-event ledger, dead-letter + replay, decoupled from run outcomein-process buffer flushed per 50 events, best-effort by designrecommended: speedway's shape; cost per item is a first-class number08
21Limits / quotasnonecheckLimits before work, soft/hard/overage modesnonerecommended: module reports, app enforces; module needs a hard ceiling per caller08
22CachingGCS-backed KV keyed by pipeline inputcross-workspace scrape cache (SERP in Firestore 7d, page text in GCS 1mo) + in-process TTL microcache for the auth chainRedis for queue + rate slots, not resultsopen: cross-service result cache; Redis on Render exists06
23Rate limiting outboundnoneOXYLABS_CONCURRENCY etc. as env capsRedis-backed fleet-wide slot limiter per vendorrecommended: walmart's shape when workers scale past one04
24Configone Config, every var has a default, zero-env import worksone flat object from process.env at loadpydantic Settings, .env + envsettled: one typed config object, defaults for local, no raw reads09
25SecretsSecret Manager, injected by reference; 8 secretsenvenvrecommended: reference-injected, never printed09
26Human in the loopnonereview queue, "review-quiet" as an orchestrator triggerPartError rows are the queue; resolve/skip re-evaluates the jobopen: module emits "needs review" as an outcome; app owns the queue10
27Completion signallingpollin-process chaining; its extractor client receives signed webhooks and runs a reconciliation sweeppoll; Walmart feed status by self-re-enqueue with backoffopen: poll is the floor, webhook is the target, sweep is the guard03
28Capability discoveryGET /usage self-documenting text for LLM agents; /docs Swaggernonenoneopen: a machine-readable manifest per module11
29Versioningnone/build-info reports commit, branch, kit, build timeGET /api/build-info reports commit SHAopen: contract version, module version, payload schema version11
30Health / readiness/health, /health/deep with GCS round-trip and effective config/livez (/healthz is edge-reserved on *.run.app), /build-info/api/build-infosettled: liveness, deep health with config echo, build identitycontracts/module-surface
31Provisioning / environmentsdeploy.sh, idempotent, env-parameterized; dev shares prod's service accountrelease.sh and deploy/deploy.sh, not interchangeablebranch→env mapping, one image four services (api/worker × prod/non-prod), Cloud Buildrecommended: walmart's branch mapping; per-env identity12
32Local dev / debuggingRUNNER_DISPATCH=local in-process semaphorein-process when Cloud Tasks vars unsetstorage_backend=local, stub_feed_submitsettled: every module runs in-process with zero env; the debug path is a contract concern13
33Output deliveryS3 for rendered images (AWS_*)exports built in-appGCS public bucket for rehosted imagesopen: how a module hands artifacts back05
34Data retentionnonenonenoneopen05
35Graceful degradation / multiple versionstwo forks of the same runner run side by side by accidentn/atwo pipeline generations in one database and one codebaseopen, and a recurring failure mode: three times the estate has run two versions of one capability (runner forks, walmart's generations, the extractor's VM and Cloud Run deployments), every time by accretion, never able to route between them (instances/extraction.md)14
36Conformancenonenonenoneopen: a checklist and a smoke suite a module can run against itselfcontracts/conformance
37Outputs and exports (M from N, partial reads, edits)/results for run-file jobs returns a resume-file dict, a transform in disguiseexports built in-app over parts and runsrehosted images to a separate public bucket; exports over Part rowssettled by App V5's output layer: outcomes raw and immutable, outputs a declared transform15, guides/01
38Data ownership split (which store, which layer is truth, where config lives)one store, GCS; config in envFirestore + GCS; per-field provenance on parts; config in config.server.ts and data/Postgres + GCS + Walmart's side; per-org flags in DBrecommended: module owns its record only, app owns identity/tenant config/catalog, reference data is a snapshot, config classified by content into five buckets; four splits comparedguides/02

Note on row 27, the extractor#

The extractor column is not a single answer, and an earlier version of this matrix carried a claim about it inside the speedway cell.

The service speedway talks to does deliver callbacks. Its client documents a service that returns 202 with a job_id and "delivers rows by webhook and/or polling", HMAC-SHA256 signed (speedway/app/lib/extractor.server.ts:5-9); speedway receives and verifies them at speedway/app/routes/tasks/extractor-webhook.tsx:20-21. The automation surface documented in instances/extraction.md offers only poll. Those are different surfaces on different deployments, so "the extractor polls" is true of one and false of another.

Two properties of that webhook are lapses rather than models. The signing key is the API key (extractor.server.ts:8-9, and :252 createHmac("sha256", config.extractorApiKey)), so a caller cannot rotate authentication without breaking signature verification, and anyone holding the API key can forge a callback. And there is no event id, so the receiver has nothing to dedup on. contracts/module-surface.md requires a separate per-caller callback secret and an X-Foundry-Event-Id; this instance is the reason both are stated explicitly.

What the matrix says#

Three shapes fall out of it, and they organise the rest of canon/.

Settled by agreement. The seam (1), the retry budget living in the handler (10), idempotency at every costly write (14), tenant on every job (3), one typed config (24), pagination on every list (17), and an in-process mode (32). Three implementations agreeing without coordination is about as good as evidence gets; the contract fixes these.

Settled by an obvious winner. Where the instances split and one is plainly better: heartbeat and cancel (11, 12; walmart's current pipeline has neither and can delete a job mid-run), usage metering (20; speedway's ledger is the shape, versable-runner's cost-per-item is the number), checkpointing as a runner helper rather than a payload convention (13; speedway copy-pasted it three times).

Open, and the contract has to design forward. Caller identity (2) is the weakest point in the estate: a shared password, a session cookie, and a JWT with no machine path, none of which can express "app A on Render, on behalf of org X, calling module B on GCP, with a budget". Job state ownership (6), per-item logs a caller can read (18), completion signalling (27), a capability manifest (28), versioning (29), and running several versions of one capability at once (35) have no good instance to copy. These are where the first modules will teach the tree the most.

The fourth witness: App V5 (enhancement-product)#

Recon landed after the matrix was written (../evidence/20260817-enhancement-product-recon.md), and its breakdown is written at ../instances/enhancement-product.md. It is a Mongo-poll pull queue on Render with Redis and Postgres beside it, and it agrees with the three above on the seam (one call, process_pipeline, lib/tasks/task_runner.py:319-320), the handler-owned retry budget, one typed Config, and per-item structured error codes. It adds mechanisms none of the other three has, now folded into the canon docs named:

It hasWhereFolded into
claim-time fairness: pick a random job first, then a task in itlib/tasks/claimer.py:80-8104, concurrency
two-tier heartbeat: Redis 15 s TTL fast path, 30-min Mongo lock sweep backstoplib/redis/worker_heartbeat.py, claimer.py:404-44904, heartbeat
fleet-wide retry budget and circuit breakers over Redis, breaker-outermost fixed by decoratorlib/redis/retry_budget.py, lib/breakers/*04, 14, contracts/runner-verbs
deferred as an outcome that does not burn an attemptlib/tasks/task_runner.py:341-35003, 04, contracts/runner-verbs
pause and resume as job and item states; running work finishesapi/jobs.py:150-170, lib/tasks/admin.py03
mixed-version fleet safety: workers claim only tasks whose names they registeredlib/scheduler/scheduler.py:276-28614
queue namespaced by git branch for PR previews on one Mongolib/config/__init__.py:88-9312
credits: worker credit-unaware, async idempotent charge, Postgres UNIQUE + ON CONFLICT DO NOTHINGlib/redis/credit_dispatch.py, .claude/notes/credit-flow.md:52-5408
Sentry centralized to one init; Langfuse spans stamped with team and userlib/sentry/__init__.py, task_runner.py:305-31107
preflight on prod boot, Slack-reported once per role and commitlib/config/__init__.py:228-23712

And the same lapses in a different costume: a shared X-Api-Token for machine callers (api/auth.py:188-206, row 2), flat team and user ownership with one hardcoded admin team and no roles (row 4), the runner importing from api/ for a credit-cycle read (a seam leak with a comment explaining it), and payload code raising runner-typed exceptions to steer routing (row 1, the deliberate version of the same coupling).

Not on the list yet#

Scheduling (auto-refresh of stale SKUs) is stage two of the replatform and is deliberately deferred. Bi-directional PIM sync is out of scope. Anything the Workflow Console needs that is not a row here gets added when v6/ is written.

@versable-git/ui · reference, canon, and method, read in place