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 class | App V5 (enhancement-product) | speedway | walmart-mvp | versable-runner |
|---|---|---|---|---|
| identity, sessions, membership | Postgres, frontend-owned (Drizzle); backend reads four tables by raw SQL | Firestore top level: users, sessions, orgs/{id}/members | Postgres User, Organization, Membership, JWT | none; one shared password |
| billing, credits, usage | Postgres ledger, written by a separate credit worker off a Redis queue; UNIQUE + ON CONFLICT DO NOTHING | Firestore orgs/{id}/usage/{period}, idempotent event ledger | Postgres UsageCounter, best-effort | GCS usage/{idx}.json per item, no tenant |
| jobs, runs, tasks | Mongo, backend-owned: jobs, runs, tasks, pipelines, job_config | Firestore workspaces/{wid}/jobs/{job_id} with runs, logs, outputs subcollections; job-siloed | Postgres Job, JobFile; stage on the row | GCS jobs/{id}/manifest.json; state derived from listings |
| items and their data | Mongo 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 documents | Postgres Part, one durable row per (org, part number), accreting per stage; PartError per (part, field) | GCS items/{idx}.json |
| outcomes | Mongo 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 stage | GCS results/, errors/ per item, write-once |
| outputs and exports | request-time transform chain over items, 60 s Redis cache; wraps a legacy exporter; N to M both ways | stateless XLSX built live from parts, ledger row only, capped 10k, readable mid-run unlabelled | legacy 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 customer | GCS JSON snapshot of Walmart's spec; TaxonomyKnowledge in Postgres | baked into the image (services-api/assets/, 122 MB) |
| per-customer configuration | Mongo 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 customer | one column (walmart_live_submit_enabled) plus global FeatureFlag rows and env; no per-org rate limits despite Walmart's per-seller tiers | none |
| files and artifacts | S3 (or local), files vs files_local collections; customer xlsx templates checked into the repo | GCS workspaces/{wid}/{sourceId}/; big scrape payloads spill to GCS behind a pointer | GCS private bucket for uploads, separate public bucket for rehosted images | GCS; images to S3 in another region |
| caches | Redis (three callers, disjoint prefixes), FastAPI-cache, per-process TTLs, Mongo KV backends | Firestore scrapeCache 7 d + GCS page text 1 mo, cross-workspace on purpose; in-process microcache | Redis rate-limit slots; a process-local verdict cache that looks like Redis and is not | GCS KV keyed by pipeline input |
| external system state | none | extractor dispatch bookkeeping in extractorDispatches/, reconciled by sweep | Walmart owns live listing state; mirrored by feed poll, reconcile_submission, and a daily unpublish sweep; the read API cannot return submitted attributes | none |
| queue | Mongo tasks polled; scheduled_tasks in a separate Mongo database | Cloud Tasks | Redis (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:
| Bucket | What it is | Natural home | Owner |
|---|---|---|---|
| identity and tenancy | users, sessions, orgs, memberships, roles | the app's user store (Postgres, Firestore); never a module | app |
| system config | pipeline definitions, output transforms, admin-authored templates, feature flags | with the runner that executes them, versioned, seeded from fixtures; App V5's job_config is the good example | app for app-side, module for module-side |
| tenant config | a customer's taxonomy, schema, accepted values, templates, load-sheet shape, rate tier, live-submit flag | the 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 data | PCdb, AutoCare, a marketplace's spec sheet, a shared taxonomy | a versioned snapshot the module loads, version reported on every outcome (../canon/05) | whoever publishes the snapshot |
| job snapshot | the exact config a run executed against | copied into the job envelope, immutable | the 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.metadatapointing 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_idreferences and not the reverse; walmart's rationale docs are gone; speedway'sconcepts.mdmarks 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_SUBMITTEDuntil 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
paramsschema 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_configwas 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'sparams(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.mdgives 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.
Related#
../canon/05-storage-and-persistence.md, the module's storage rules../canon/09-config-and-secrets.md, config in a module../canon/15-outputs-and-transforms.md, outputs over the record../v6/README.md, the Catalog and the system-of-record question, app-side- the four recons in
../evidence/20260818-data-model-split/