diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 46ca33d..6168027 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -17,6 +17,7 @@ | workplan | TEN-WP-0006 | finished | — | workplans/TEN-WP-0006-guardrail-quota-policy.md | | workplan | TEN-WP-0007 | finished | — | workplans/TEN-WP-0007-guardrail-production-rollout.md | | workplan | TEN-WP-0008 | ready | — | workplans/TEN-WP-0008-staged-promotion-onboarding.md | +| workplan | TEN-WP-0009 | ready | — | workplans/TEN-WP-0009-postgres-production-store.md | | task | ADHOC-2026-07-24-T01 | done | — | workplans/ADHOC-2026-07-24.md | | task | TEN-WP-0001-T01 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md | | task | TEN-WP-0001-T02 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md | @@ -53,3 +54,9 @@ | task | TEN-WP-0008-T02 | todo | — | workplans/TEN-WP-0008-staged-promotion-onboarding.md | | task | TEN-WP-0008-T03 | todo | — | workplans/TEN-WP-0008-staged-promotion-onboarding.md | | task | TEN-WP-0008-T04 | todo | — | workplans/TEN-WP-0008-staged-promotion-onboarding.md | +| task | TEN-WP-0009-T01 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | +| task | TEN-WP-0009-T02 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | +| task | TEN-WP-0009-T03 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | +| task | TEN-WP-0009-T04 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | +| task | TEN-WP-0009-T05 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | +| task | TEN-WP-0009-T06 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | diff --git a/workplans/TEN-WP-0009-postgres-production-store.md b/workplans/TEN-WP-0009-postgres-production-store.md index 507fee5..4ceb828 100644 --- a/workplans/TEN-WP-0009-postgres-production-store.md +++ b/workplans/TEN-WP-0009-postgres-production-store.md @@ -13,6 +13,7 @@ depends_on: - TEN-WP-0007 unblocks: - TEN-WP-0008 +state_hub_workstream_id: "3bf3b4ab-a116-4914-b2d8-d3c430754956" --- # TEN-WP-0009 - PostgreSQL production store @@ -57,33 +58,88 @@ So **no secret enters this repo, this workplan, or any evidence dump**, and no or rotate. Runtime secret custody remains OpenBao's per `SCOPE.md`; an operator-minted in-cluster secret is outside that lane. -## T01 - Decide the topology and write the cluster manifest +## Placement: shared by default, movable by design + +Dedicated-vs-shared is **not a decision this workplan should settle once**, and +`tenant-engine` is the wrong place to settle it for the platform. The estate +has distinct elements — railiance, NetKingdom, HelixForge, Coulomb — plus +tenants layered on top, and which repo belongs to which grouping is not yet +organised. On top of that, isolation level is heading toward being a +*product* property: plans will differ in how much isolation they buy. + +So the requirement here is **portability, not placement**: + +- start on a **shared** cluster, because it is cheaper on a single node and + nothing yet justifies dedicated capacity; +- make moving to a dedicated cluster — or to a different shared one — an + **operational change, not a code change**; +- never let the decision leak into the application. + +What that demands of this repo, and all of it is cheap if done now and +expensive to retrofit: + +1. **Connect by injected URL only.** No cluster name, namespace, host, or + database name anywhere in `src/`. The application must be unable to tell + whether it is on a shared or dedicated cluster. +2. **Own a whole database, not a set of tables in someone else's.** Sharing a + *cluster* is a capacity decision; sharing a *database* would entangle + schemas and make relocation a merge rather than a move. A dedicated + database inside a shared cluster relocates with a dump and a restore. +3. **No cross-database joins or co-location assumptions**, which the repo + boundary already forbids — `tenant_id` is the only key shared with + `user-engine`. Placement must not quietly become a dependency. +4. **Idempotent schema creation**, so a fresh target comes up correct without a + hand-built database. + +Then "move `tenant-engine` to dedicated" is: create the target cluster, dump, +restore, swap the `secretKeyRef`, restart. No rebuild, no release, no code +review. + +**What this repo does not own:** the placement *policy* — which element or +tenant gets dedicated capacity and when. That is a platform concern, and +`tenant-engine` implementing its own would be the same boundary error as +building a fleet drift-detector because we got bitten. Raised with +`railiance-platform` separately; see T01. + +## T01 - Make placement portable and provision on shared capacity ```task id: TEN-WP-0009-T01 status: todo priority: high +state_hub_task_id: "b4701256-f235-4474-9dcf-7cb09f62b873" ``` -Decide between a dedicated `tenant-engine-pg` cluster in our namespace and -sharing an existing one (`net-kingdom-pg` or `apps-pg` in `databases`). +Provision a dedicated **database** on an existing **shared** cluster, per the +principles above. Candidates are `net-kingdom-pg` (already exists, and +`tenant-engine` is a NetKingdom service) and `apps-pg`. Prefer the one whose +grouping matches the product family, since that is the axis a future split +would most likely follow — moving is cheap, but moving less often is cheaper. -Argue it rather than defaulting. Points to weigh: `user-engine` chose dedicated -and is the closest precedent; a dedicated cluster keeps blast radius and backup -policy per-service; a shared cluster is cheaper on a single node and -`net-kingdom-pg` already exists for this product family. Note that -`tenant-engine` is consulted by `flex-auth` on the authorization path, so its -availability envelope is not purely its own. +Note the deviation from precedent and why: `user-engine` runs a dedicated +`user-engine-pg`. We are deliberately *not* copying that. Their choice is fine +and ours is reversible in an afternoon; picking dedicated now would spend +capacity on an isolation guarantee nothing has asked for yet. -Write the CNPG `Cluster` manifest into `deploy/`, matching the fleet's existing -image and storage conventions rather than inventing new ones. +Record what would trigger a move to dedicated, so the reversal is a judgement +already made rather than one improvised under pressure. Candidate triggers: +a noisy neighbour affecting the authorization path, a compliance or residency +requirement, a plan tier that sells isolation, or the shared cluster's backup +policy no longer fitting. -Also decide **backups**. The `scheduledbackups.postgresql.cnpg.io` CRD is -available; SQLite-on-a-PVC had no backup story at all, so this is a gain to -claim explicitly rather than inherit by accident. +Also decide **backups**: whether the shared cluster's existing +`scheduledbackups` policy covers us, or we need our own. SQLite-on-a-PVC had no +backup story at all, so this is a gain to claim explicitly rather than inherit +by accident — and a shared cluster means inheriting *someone else's* retention +choice, which is worth checking rather than assuming. -Done when the topology decision is recorded with its reasoning and the manifest -is committed. +Write whatever manifest the choice needs into `deploy/` — a CNPG `Database` +resource against the shared cluster, not a new `Cluster`, if that is the +correct shape. + +Done when the database exists on shared capacity, the connection reaches the +app only through an injected URL, and the move-to-dedicated trigger list and +runbook are recorded. ## T02 - Implement `PostgresTenantStore` @@ -91,6 +147,7 @@ is committed. id: TEN-WP-0009-T02 status: todo priority: high +state_hub_task_id: "d7428bc1-2e5f-4a4c-93f5-9368ebb691d5" ``` Implement the full `TenantStore` Protocol — including @@ -128,6 +185,7 @@ Done when all three backends pass the same conformance suites unchanged. id: TEN-WP-0009-T03 status: todo priority: high +state_hub_task_id: "89c121c6-8bc5-4abe-80f0-d11406aeffc2" ``` `Settings` currently carries only `database_path`. Add a database URL and make @@ -156,6 +214,7 @@ by tests. id: TEN-WP-0009-T04 status: todo priority: high +state_hub_task_id: "51a83eae-9e9f-4676-bc1d-9c2d884bac0d" ``` Move the live SQLite database to PostgreSQL. Currently small — two tenants @@ -191,6 +250,7 @@ recorded here. id: TEN-WP-0009-T05 status: todo priority: high +state_hub_task_id: "03aeae3b-5e0f-4c77-a763-787ae08078f5" ``` Update `deploy/tenant-engine.yaml`: @@ -223,6 +283,7 @@ service. id: TEN-WP-0009-T06 status: todo priority: medium +state_hub_task_id: "e7adbc64-ad1b-4f28-bfd0-0b07034629db" ``` - `flex-auth`: availability characteristics of their data source changed. No @@ -243,4 +304,13 @@ Done when the notes are sent and TEN-WP-0008-T02 is updated. change at the same time makes any regression ambiguous. - **Sharing a database with `user-engine`.** `tenant_id` is the only key the two services share, and it stays that way — separate stores are the boundary, - not an implementation detail. + not an implementation detail. Sharing a *cluster* is fine; sharing a + *database* is not. +- **Fleet placement policy.** Which element or tenant gets dedicated capacity, + and how isolation maps to plan tiers, is a platform decision. This workplan + makes `tenant-engine` movable and stops there. +- **Per-tenant isolation.** A future plan tier may sell dedicated + infrastructure. `tenant-engine` already records plan assignment by id, and + `adaptive-pricing` owns what a plan *means*, so neither the tier definition + nor the placement that implements it belongs here. Worth designing for — + which the portability principles above do — not worth building for.