Agent docs

Guide: data ownership, stores, and where configuration lives

Which store holds which entity, which layer is the source of truth for it, who writes it, and where…

Which store holds which entity, which layer is the source of truth for it, who writes it, and where configuration goes. Every sibling took this decision differently, each split fit its first purpose and then produced its own misplacements, and the owner asked for the splits to be examined thoroughly and compared, "the mistakes made there and other good/bad comparison and deliberation", because the module tree's split "may be quite different in various ways, and not the most ideal one all the times". This guide is that comparison, from four recons on disk (../evidence/20260818-data-model-split/, and ../evidence/20260817-runner-architecture.md for versable-runner). The extractor waits on its source.

Audience: anyone deciding where an entity or a config value lives, in a module or an app.

The four splits, side by side#

Entity classApp V5 (enhancement-product)speedwaywalmart-mvpversable-runner
identity, sessions, membershipPostgres, frontend-owned (Drizzle); backend reads four tables by raw SQLFirestore top level: users, sessions, orgs/{id}/membersPostgres User, Organization, Membership, JWTnone; one shared password
billing, credits, usagePostgres ledger, written by a separate credit worker off a Redis queue; UNIQUE + ON CONFLICT DO NOTHINGFirestore orgs/{id}/usage/{period}, idempotent event ledgerPostgres UsageCounter, best-effortGCS usage/{idx}.json per item, no tenant
jobs, runs, tasksMongo, backend-owned: jobs, runs, tasks, pipelines, job_configFirestore workspaces/{wid}/jobs/{job_id} with runs, logs, outputs subcollections; job-siloedPostgres Job, JobFile; stage on the rowGCS jobs/{id}/manifest.json; state derived from listings
items and their dataMongo items + item_context_rows (1:N sheet data kept apart, deliberately)Firestore jobs/{id}/parts/{sku} with records/{sourceId} and fitments; the same SKU in two jobs is two documentsPostgres Part, one durable row per (org, part number), accreting per stage; PartError per (part, field)GCS items/{idx}.json
outcomesMongo tasks.result, items.result; typed causes[]per-field values on the part with runId, source, evidence, resolvedAt (provenance on every value)slices on Part overwritten per stageGCS results/, errors/ per item, write-once
outputs and exportsrequest-time transform chain over items, 60 s Redis cache; wraps a legacy exporter; N to M both waysstateless XLSX built live from parts, ledger row only, capped 10k, readable mid-run unlabelledlegacy generation has an export/ layer; current has none, publish builds the payload live and only the submission's payload_json persists/results for run-file jobs is a transform in disguise
reference data (taxonomy, schema, accepted values, PCdb)a second read-only Mongo deployment (AutoCare/PCDB)baked into the image from xlsx and JSON, identical for every customerGCS JSON snapshot of Walmart's spec; TaxonomyKnowledge in Postgresbaked into the image (services-api/assets/, 122 MB)
per-customer configurationMongo job_config (admin-authored, seeded from fixtures) and Postgres job_templates (user-authored, pipeline-shaped)mostly global: taxonomy, schema, accepted values, load sheets, upload template are staff-managed and shared; only content templates (frozen into the job at consent) and per-job scrape config are per customerone column (walmart_live_submit_enabled) plus global FeatureFlag rows and env; no per-org rate limits despite Walmart's per-seller tiersnone
files and artifactsS3 (or local), files vs files_local collections; customer xlsx templates checked into the repoGCS workspaces/{wid}/{sourceId}/; big scrape payloads spill to GCS behind a pointerGCS private bucket for uploads, separate public bucket for rehosted imagesGCS; images to S3 in another region
cachesRedis (three callers, disjoint prefixes), FastAPI-cache, per-process TTLs, Mongo KV backendsFirestore scrapeCache 7 d + GCS page text 1 mo, cross-workspace on purpose; in-process microcacheRedis rate-limit slots; a process-local verdict cache that looks like Redis and is notGCS KV keyed by pipeline input
external system statenoneextractor dispatch bookkeeping in extractorDispatches/, reconciled by sweepWalmart owns live listing state; mirrored by feed poll, reconcile_submission, and a daily unpublish sweep; the read API cannot return submitted attributesnone
queueMongo tasks polled; scheduled_tasks in a separate Mongo databaseCloud TasksRedis (arq)Cloud Tasks

The four ways, and what each bought and cost#

Split by audience (App V5): Postgres for what users touch (identity, billing, templates), Mongo for what the system processes (jobs, items, tasks, configs), Redis as transport. Enforced by process, not convention: the frontend has no Mongo access, the backend has a four-table Postgres reader, no process writes more than two stores. What it bought: crisp boundaries, a credit ledger with structural idempotency, and a config system (job_config with its pipeline tree and output_transform tree) that made M≠N solvable without touching the engine. What it cost: anything that is both user-facing and pipeline-shaped had to pick a side, and job_templates picked the wrong one: it lives with billing and identity in Postgres, embeds a pipeline extension and a column-map DSL that are the same kind of content as Mongo's job_config, and is pointed at from Mongo Job.metadata by bare string ids with no foreign key either way and no entry in the system's own drift table (enhancement-product.md §4). Two template systems with colliding names in two stores; two export mechanisms where the old wraps the new; customer xlsx templates as binaries in git.

One document store, job-siloed (speedway): everything under workspaces/{wid}/jobs/{job_id}, parts private to a job, provenance on every value, path construction centralized so a hand-built path cannot cross a silo. What it bought: an unusually thorough provenance model, idempotency everywhere, and a concepts.md that orients a reader fast. What it cost: no workspace-global catalog (the same SKU twice is two documents, by contract), exports that read a mid-run job as an unlabelled partial, and a "per-customer" story that is mostly global: taxonomy, schema, accepted values, load sheets, and the upload template are baked or staff-managed and shared by every org, so a staff edit to a load sheet changes ingest for every customer at once (speedway.md §4). Plus two doc-versus-code drifts a reader could act on wrongly (IngestDoc implemented but marked planned; an export schema described that was deleted).

One relational store, part ledger, external system of record (walmart): Part as a durable per-(org, part number) row that accretes per stage, PartError as the review queue, and Walmart owning live state that the app mirrors through three mechanisms with different failure modes and never owns. What it bought: joinable, queryable state, a clean review-queue model, and an honest stance toward the external system (the read API cannot return submitted attributes, so the app keeps its own locked payload). What it cost: two pipeline generations in one database and one file (spec/service.py serving both), an export layer only the legacy one has, per-org configuration that is one column, and a design-rationale trail (REBUILD_PLAN.md and siblings) cited by section number from code and no longer present in the repo (walmart-mvp.md §7).

One object store, no state table (versable-runner): GCS is the only store, state is derived from listings, reference data is baked. What it bought: nothing to corrupt, nothing to migrate, idempotency by if_generation_match=0. What it cost: no tenant, no identity, no config beyond env, and a listing that will stop being cheap (../canon/05-storage-and-persistence.md, the condition).

What the misplacements have in common#

Reading the four together, the misplacements are not random. Each is a value that is one kind of thing by content and another kind by audience, placed by audience:

  • job_templates: user-authored (audience: user) but pipeline-shaped (content: system config). Placed with users; belongs with configs.
  • speedway load sheets and taxonomy: named after customers (audience: customer) but global reference data (content: shared). Placed globally, which is right, and named as if per-customer, which misleads.
  • walmart's walmart_items_rpm: a per-seller value by content, a global env default by placement.
  • App V5's xlsx template files: files by content (S3), config by use, source by placement.
  • App V5's export mechanisms: one transform by content, two by history.

The classification that would have prevented each is by content, and it has five buckets that every sibling has, whether or not it names them:

BucketWhat it isNatural homeOwner
identity and tenancyusers, sessions, orgs, memberships, rolesthe app's user store (Postgres, Firestore); never a moduleapp
system configpipeline definitions, output transforms, admin-authored templates, feature flagswith the runner that executes them, versioned, seeded from fixtures; App V5's job_config is the good exampleapp for app-side, module for module-side
tenant configa customer's taxonomy, schema, accepted values, templates, load-sheet shape, rate tier, live-submit flagthe app, keyed by tenant, and passed per job; frozen into the job at consent so later edits do not mutate a running job (speedway job.moduleConfig.contentGen, types.ts:696-698)app
reference dataPCdb, AutoCare, a marketplace's spec sheet, a shared taxonomya versioned snapshot the module loads, version reported on every outcome (../canon/05)whoever publishes the snapshot
job snapshotthe exact config a run executed againstcopied into the job envelope, immutablethe runner

And a sixth that is not config: the record (jobs, items, outcomes, usage), owned by the runner that writes it, immutable per outcome, with outputs as a transform over it (../canon/15).

Two properties recur in every instance and are worth stating as the choosing rules:

  • Ownership follows the writer. The store an entity lives in is the store of the process that writes it, and readers cross the boundary through an API or a snapshot, never a shared table. App V5 enforced this by process and got crisp boundaries; the one place it did not (Job.metadata pointing into Postgres) is the one the owner remembers.
  • A pointer across stores is a drift risk that must be named. App V5's drift table documents Postgres to Mongo job_id references and not the reverse; walmart's rationale docs are gone; speedway's concepts.md marks a built thing planned. When two stores reference each other, the reference and its consistency check go in the same document as the schema, or they rot.

The external system of record#

walmart-mvp is the estate's one worked case and its lessons carry to any future marketplace or PIM integration:

  • the external system owns live state; the app keeps a locked copy of what it submitted (payload_json) because the provider's read API will not return it later
  • three mirroring mechanisms, each for a failure mode the others miss: poll after submit with backoff and a ceiling; reconcile that never trusts a summary count; a periodic sweep for state that changed without the app acting, off by default behind flags
  • everything the provider is authoritative for is marked as such on the app's row (PART_STATUS_SUBMITTED until the verdict), and the app never writes a value it did not get from the provider

Under the contract this is app-side, and a marketplace connector, if one is built as a module, has these three mechanisms as its runner primitives (../canon/04, wait_for and the sweep in ../canon/03).

What the module tree chooses#

The contract's split, and why it is a choice rather than the only one:

  • A module owns its record and nothing else: jobs, items, outcomes, usage, logs, artifacts, its result cache, keyed by tenant and job, immutable per outcome (../canon/05). It holds no identity, no tenant config, no customer catalog. That is the walmart-and-speedway lesson (per-customer config thin or global because it had nowhere else to go) and the App V5 lesson (a system-shaped config in a user store) taken together: give tenant config one home, in the app, and make the module take it per job.
  • The app owns identity, tenancy, tenant config, the catalog, review, and its own outputs, in whatever store fits the app (Postgres for walmart, Firestore for speedway, both for App V5); the contract does not care, because the module never reads it.
  • Reference data is a versioned snapshot with a publisher, loaded by modules, never a live cross-store read (../canon/05).
  • Job snapshot is the envelope: what a run executed against travels with the job and is returned by /jobs/{job_id}/envelope, so "which template did this run use" is a read, not an archaeology.
  • Outputs are a transform over the record (../canon/15), so no store needs an output table that must be kept consistent with a running job.

Where this may not be ideal, said plainly: an app that wants to query across module outcomes and its own catalog in one SQL statement cannot, because they are in two stores by design; it mirrors what it needs (../canon/03, caller-owned state) and pays a join by id. A team of one building one app on one module will feel that as overhead. The estate has four apps and five capabilities, and the copy-per-app cost was higher.

Settled by the owner, 2026-08-18#

  • Job templates should have lived in Mongo, colocated with jobs. They are pipeline-shaped and job-shaped; the Postgres placement followed the audience, not the content. Under the module tree that is tenant config in the app, validated against the module's params schema and frozen into the job envelope; the "with jobs" instinct survives as "travels with the job".
  • Credits were forced into Postgres by users and auth, not chosen for their own sake, and it was still the better choice: the pre-credit system was "an expensive sum on mongo jobs / items / runs / tasks, very crude and inaccurate and inefficient". So the credit ledger's home is a consequence of identity's home, which is why the contract puts identity and billing on the app side together and gives the module only usage events (../canon/08).
  • job_config was a mega-entity: execution, rendering, data transformation, auth, and more in one document, hand-rolled per customer alongside development, so the system never had to face true mix-and-match. The five buckets above are the split it never had; the contract's params (execution), outputs (transformation and rendering shape), and the app's own UI config are the three that were fused.
  • Seeding pipelines and job configs into Mongo was useless in practice: the team cared about correctness and resilience over on-the-fly editability. Lesson for the module tree: system config is code, versioned with the runner that executes it, and a database is where tenant config and job snapshots live, not where the pipeline definition does.
  • Customer-specific template work (JEGS, Zeder) was hardcoded in the frontend, tightly coupled across FE and BE. That is the bespoke layer with no named home; ../01-system-classes.md gives it one.
  • All of it "SUCCESSFUL for fulfilling precise user requirements but somewhat loaded with architectural compromises and ugly coupling", in the owner's words, and that is the honest frame for every instance in this tree.

Still to settle with the owner#

  • speedway and walmart's misplacements are from the recon, not from the owner, who has less experience with the newer MVPs; they stand as findings for whoever owns those apps.
  • The extractor's split, once its source is found.
@versable-git/ui · reference, canon, and method, read in place