Skip to content

Latest commit

 

History

History
58 lines (50 loc) · 3.89 KB

File metadata and controls

58 lines (50 loc) · 3.89 KB

ADR 0021: Data-plane CDK stack — single tenant table, date-bucketed due-work GSI

  • Status: Accepted (2026-08-23)
  • Implements: roadmap M4 infra (AD1/AD3/AD4); parameterization per AD9/ADR-0010

Context

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.

Decision

  1. One table for every TenantRepository-backed entity (TenantTable): partition key userId, sort key sk — exactly the key shape entities.scoped_key already 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.
  2. DueWorkIndex gets its own table (DueWorkTable), because it has a genuinely different access pattern: the dispatcher's cross-tenant due_before scan (ADR-0018's documented exception) has no analog anywhere in TenantRepository. Base keys (userId/sk) serve the tenant-validated writes; a DueIndex GSI serves the scan, keyed on duePartition (the UTC date portion of nextDueAt) with nextDueAt as 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.
  3. No AWS account ID is ever a literal in source. bin/app.ts resolves account/region only from CDK_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 to undefined and cdk synth produces an environment-agnostic template, which is what CI runs — no AWS credentials needed just to validate the stack synthesizes.
  4. envName (dev/stage/prod) is the one required, validated parameter, sourced from CDK context or OPENJOBRADAR_ENV, fails closed on anything else. It drives the only environment-shaped behavior these tables have today: RemovalPolicyRETAIN in prod, DESTROY elsewhere, so ephemeral dev/PR stacks (AD9) tear down cleanly and prod data can never vanish via cdk destroy.
  5. The purge gate now scans infra/{bin,lib,test} alongside src/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.

Consequences

  • A future compute stack (Lambda dispatcher, API Gateway) imports these tables by the CfnOutput names rather than re-deriving key schemas; the schema is decided once, here.
  • TableEncryption.AWS_MANAGED is every row's baseline at-rest protection; Credentials rows 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 infra job (npm ci && npm run build && npm test && npx cdk synth), fulfilling M0's original, previously-unmet "cdk synth on PRs" exit criterion.