Agent docs

Storage and persistence

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

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.md forge-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-runnerspeedwaywalmart-mvp
inputspayload.json + items/{idx}.json in GCS, one bucket per envfiles in GCS (workspaces/{wid}/{sourceId}/{filename}), rows in Firestorefiles in GCS ({org_id}/jobs/{uuid}_{filename}), JobFile rows in Postgres
outcomesresults/, errors/, usage/ per item in GCSrun doc + chunked log subcollection; denormalized stages on the job docPart row per item, PartError per (part, field)
artifactsrendered images to S3 ap-southeast-2exports built in-apprehosted images to a public GCS bucket, separate from the private uploads bucket
reference dataassets/ baked into the image (122 MB vectors, 45 MB records, 17 MB SQLite)data/ baked into the imagePCdb via pcdb/
retentionnonenonenone
state tablenone, by designFirestore docsPostgres 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), with if_generation_match=0 or 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's Part and PartError. 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:

ClassDevProd
inputs7 days90 days
outcomes30 days1 year
artifacts7 days90 days, or until the app has copied them
per-item logs7 days30 days
usage eventsnever deleted; they are the billing recordnever
cachesby TTL, per cacheby 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.
@versable-git/ui · reference, canon, and method, read in place