- Status: Accepted (2026-08-23)
- Implements: roadmap M4 infra (AD1/AD3/AD4); parameterization per AD9/ADR-0010
Every ADR since ADR-0016 has described storage protocols (Store, TenantRepository,
DueWorkIndex) without a deployable backend — InMemoryStore stood in for tests, and
store.py's own docstring named this gap explicitly ("the DynamoDB adapter... will implement
the same protocol"). This ADR is the first infrastructure-as-code in the repo (infra/, CDK v2
in TypeScript) and makes two concrete decisions the prior ADRs left open.
Roadmap Appendix B originally sketched one DynamoDB table per entity (Users, Settings, Orgs,
Seen, Postings, ...). That sketch predates entities.py, which already unified every entity
behind one interface (entity + userId + sk) because they share one access pattern:
get/put/delete/list-by-prefix, scoped to a single tenant. Appendix B's table-per-entity design is
superseded by that interface, not implemented by it.
- One table for every
TenantRepository-backed entity (TenantTable): partition keyuserId, sort keysk— exactly the key shapeentities.scoped_keyalready computes (sk = f"{entity}:{value}"). N tables would be N copies of an identical access pattern with no different query shape to justify the split. DueWorkIndexgets its own table (DueWorkTable), because it has a genuinely different access pattern: the dispatcher's cross-tenantdue_beforescan (ADR-0018's documented exception) has no analog anywhere inTenantRepository. Base keys (userId/sk) serve the tenant-validated writes; aDueIndexGSI serves the scan, keyed onduePartition(the UTC date portion ofnextDueAt) withnextDueAtas the sort key — a dispatcher queries a handful of day-bucket partitions instead of scanning an unbounded keyspace, the standard pattern for "everything due before X" at scale.- No AWS account ID is ever a literal in source.
bin/app.tsresolves account/region only fromCDK_DEFAULT_ACCOUNT/CDK_DEFAULT_REGION(populated by whichever CLI profile is active at deploy time) — the exact opposite of roadmap item A8's named failure mode, a hardcoded account ID baked into the prototype's CDK stack. Left unset, both resolve toundefinedandcdk synthproduces an environment-agnostic template, which is what CI runs — no AWS credentials needed just to validate the stack synthesizes. envName(dev/stage/prod) is the one required, validated parameter, sourced from CDK context orOPENJOBRADAR_ENV, fails closed on anything else. It drives the only environment-shaped behavior these tables have today:RemovalPolicy—RETAINin prod,DESTROYelsewhere, so ephemeral dev/PR stacks (AD9) tear down cleanly and prod data can never vanish viacdk destroy.- The purge gate now scans
infra/{bin,lib,test}alongsidesrc/openjobradar(tests/test_purge_gate.py), not as a follow-up — item A8 names infrastructure code specifically, so the gate had to grow the day infra source existed, not after.
- A future compute stack (Lambda dispatcher, API Gateway) imports these tables by the
CfnOutputnames rather than re-deriving key schemas; the schema is decided once, here. TableEncryption.AWS_MANAGEDis every row's baseline at-rest protection;Credentialsrows still get the additional per-user KMS envelope at the application layer (ADR-0007,tenancy/vault.py) — the table's encryption is not a substitute for that.- Point-in-time recovery is on for both tables from day one; a restore drill (roadmap M8) has something to drill against.
- CI gained an
infrajob (npm ci && npm run build && npm test && npx cdk synth), fulfilling M0's original, previously-unmet "cdk synth on PRs" exit criterion.