Evidence: 3 file:line witnesses as of 2026-08-18. Confidence: partial, some rules witnessed, the rest inherited from the seams. What changes it: the first module built against this doc (
../PLAN.mdforge-1) and its instance breakdown.
Where a module keeps a job's inputs, its outcomes, the artifacts it produces, and the reference data it needs, for how long, and the one persistence decision that decides everything else: whether the module has a state table at all. The instances cover the whole spectrum (GCS only; Firestore + GCS; Postgres + GCS), so this doc is a recommendation with conditions rather than a single mandate.
Audience: anyone choosing a module's storage, or wondering where a thing should live.
What the instances do#
| versable-runner | speedway | walmart-mvp | |
|---|---|---|---|
| inputs | payload.json + items/{idx}.json in GCS, one bucket per env | files in GCS (workspaces/{wid}/{sourceId}/{filename}), rows in Firestore | files in GCS ({org_id}/jobs/{uuid}_{filename}), JobFile rows in Postgres |
| outcomes | results/, errors/, usage/ per item in GCS | run doc + chunked log subcollection; denormalized stages on the job doc | Part row per item, PartError per (part, field) |
| artifacts | rendered images to S3 ap-southeast-2 | exports built in-app | rehosted images to a public GCS bucket, separate from the private uploads bucket |
| reference data | assets/ baked into the image (122 MB vectors, 45 MB records, 17 MB SQLite) | data/ baked into the image | PCdb via pcdb/ |
| retention | none | none | none |
| state table | none, by design | Firestore docs | Postgres rows |
Nobody has retention. Everybody bakes reference data into the image. Two of three keep bytes and rows in different systems on purpose.
The rule#
Bytes go in object storage, keyed by tenant and job; per-item outcomes are individually addressable; reference data is a snapshot, versioned and loaded, never a live connection to another system's database; and every module states its retention.
Inputs#
Uploaded files and submitted payloads are bytes. They go to object storage (GCS on GCP, S3 elsewhere, a local directory in the in-process mode) under a prefix that starts with the tenant and the job:
{env-bucket}/tenants/{tenant}/jobs/{job_id}/input/…Tenant first, so a tenant's data can be listed, exported, or deleted as one
prefix, and so a lifecycle rule can differ per tenant later. versable-runner's
jobs/{job_id}/… has no tenant because it has no tenancy; that is the one
change its layout needs.
Big submissions (a 5,000-item payload) are written once as one object and
expanded by the fanout (04-dispatch-and-workers.md), not written as N
objects in the request.
Outcomes#
One record per item, addressable by item_id, written once
(create-if-absent), and never overwritten. That is what makes derived state
possible and idempotent redelivery harmless (03-jobs-and-state.md,
04-dispatch-and-workers.md).
Two storage shapes satisfy this, and the choice follows the state decision below:
- Object per outcome (
…/jobs/{job_id}/outcomes/{item_id}.json), withif_generation_match=0or the equivalent precondition. versable-runner. Listing a prefix gives the counts. Cheap, durable, no schema migrations, and it stops scaling when a caller wants to query outcomes by a field that is not in the key. - Row per outcome in a database, with a unique key on
(job_id, item_id). walmart-mvp'sPartandPartError. Queryable, joinable, and a schema to migrate.
Either way, the outcome record carries what 03-jobs-and-state.md says it
carries, and the caller reads it through the same surface.
The state-table decision#
versable-runner has no state table: job status is a count of outcome objects
against the manifest's item count. There is no status column to go stale, no
state machine to corrupt, no lock a crashed worker leaves behind. This is the
single most load-bearing decision in that design
(../evidence/20260817-runner-blueprint-approach.md), and it comes with a
condition the contract states rather than inherits:
Derive from a listing while a listing is cheap and the key is all you ever query by. Concretely, when:
- outcomes are individually addressable and a prefix listing (or a count query on a unique key) returns in well under the polling interval, and
- callers only ever ask for a job by id, and for outcomes by job and type, and
- the job list is small enough to filter in memory after listing, or is indexed elsewhere (a small jobs index table is fine and does not make the outcomes stateful)
Once a caller needs to query across jobs by attribution, tenant, capability, and window (and the console will), the jobs index becomes a table with one row per job, and that is not a betrayal of the derived-state idea: the per-item truth still lives in the outcome records, and the index is rebuildable from them. What the contract does not want is a per-item status column that a worker has to keep in sync with the outcome it just wrote.
If a module does keep per-item state (walmart's Part.status), it keeps one
mutation point and treats the outcome record as the source the state is
rebuilt from.
Artifacts#
Things a module produces that are not the outcome record itself: rendered
images, generated files, exports. They go to object storage under the same
tenant/job prefix, and the outcome carries a reference (bucket, key, size,
content type, expiry), never the bytes. Delivery to a caller is a signed URL
with a short lifetime, minted on request, so nothing in a bucket is public
by default. walmart's public rehosted-images bucket is the exception the
contract allows only when the artifact is meant to be public and the bucket
is separate from everything private, which is how walmart did it
(config.py:138-144).
versable-runner's image generation writing to S3 in ap-southeast-2 while
everything else is on GCS is a lapse worth naming: two clouds' credentials
for one module's outputs. If the destination is a customer's or a partner's
bucket, that is a delivery step the app owns; a module's own artifacts go
where the module's own storage is.
Reference data: the snapshot pattern#
Part-type taxonomies, attribute schemas, PCdb, accepted values: the payloads
need them, and today they live in App V5's Mongo and Pinecone. Every fork so
far has solved this the same way, by hand: services-api baked a 122 MB
vector file, a 45 MB records file, and a 17 MB SQLite into its image, which
is exactly what runner-service's own roadmap said it needed
(src/services-api/docs/runner-service.md, "Roadmap"). The Anh-Tuan notes
in Von's doc propose a generic "Snapshotter"
(../evidence/20260817-source-docs-skeptical-read.md, single-document
flags), and it is the one idea in those notes the contract adopts whole.
The rule: a module never opens a live connection to another system's
database for reference data. It loads a snapshot: a versioned artifact in
object storage (NDJSON, SQLite, a vector index) that the module downloads at
boot or bakes at build, and whose version is reported in /health/deep and
recorded on every outcome that used it (reference_versions on the
outcome). Two consequences:
- reproducibility: an outcome can say which taxonomy version it was matched against, and a re-run against a newer snapshot is a new job
- decoupling: App V5's Mongo can move, change, or be turned off without a module noticing until it asks for the next snapshot
The snapshot tool itself (query or aggregation in, versioned artifact out) is one utility, written once, and belongs in the template code. Bake-into-image is fine below roughly 200 MB and slows every deploy above it; download-at-boot with a local cache is the alternative, and either way the version is explicit.
Caches are storage too#
Result caches keyed by input (06-caching.md) live in the same object store
or in Redis, under their own prefix with their own TTL, and are never
tenant-scoped unless the cached thing is tenant data. speedway's scrape cache
is deliberately cross-workspace because "a page's content is not workspace
data" (app/lib/scrapecache.server.ts:1-11); the runner's enhancement cache
is keyed by pipeline input.
Retention#
Every module states, in its manifest, how long it keeps inputs, outcomes, artifacts, and logs, per environment, and enforces it with a lifecycle rule on the bucket (or a scheduled purge on the table), not with a promise. Nobody has this today, and the buckets grow forever. Defaults to start from, adjust per module:
| Class | Dev | Prod |
|---|---|---|
| inputs | 7 days | 90 days |
| outcomes | 30 days | 1 year |
| artifacts | 7 days | 90 days, or until the app has copied them |
| per-item logs | 7 days | 30 days |
| usage events | never deleted; they are the billing record | never |
| caches | by TTL, per cache | by TTL |
A tenant's deletion request is a prefix delete plus a usage-event tombstone; the tenant-first key layout is what makes it one operation.
Vendor notes#
Object storage is GCS on GCP and S3 elsewhere, behind one small port (put,
get, list by prefix, delete by prefix, signed URL, create-if-absent). GCS
if_generation_match=0 and S3 If-None-Match: * are the create-if-absent
preconditions. Firestore and Postgres are both fine as a jobs index and as a
row-per-outcome store; Firestore's transaction and listing costs are what
made speedway denormalize a stages rollup onto the job doc so list views
never scan runs (app/lib/jobs.server.ts:250-311), and that pattern is
worth copying wherever the store charges per read.
Do-nots#
- Do not key storage without the tenant. (versable-runner
jobs/{job_id}/…) - Do not overwrite an outcome. Create-if-absent, once.
- Do not open a live connection to another system's database for reference data. Snapshot it, version it, report the version.
- Do not keep a per-item status column a worker must keep in sync with the outcome it just wrote.
- Do not put a module's own artifacts on a different cloud from its own storage. (versable-runner image output to S3)
- Do not make a bucket public unless the artifact is meant to be public and the bucket holds nothing else. (walmart's split is the allowed shape)
- Do not ship without a retention rule. Buckets do not clean themselves.