From 7c69153da9188acf51fe3e905b4b879bc37be7b6 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 17 Aug 2026 13:07:41 +0200 Subject: [PATCH 1/7] research: verify pgrls directly; add maturity caveat before recommending adoption Checked the repo rather than trusting the search snippet. Confirms the capability claims (MIT, 67 rules, 54 of them security, Z3 isolation proofs, SAFE/BREAKING policy-diff for CI gating, pytest plugin, PG 15-17) and corrects the auto-fixable count from 20 to 19. It is also 26 stars and self-described beta. That does not change the recommendation to adopt the approach, but it does change the recommendation to adopt the tool: it is a supply-chain decision, not a free win. pgTAP is long-established and carries no such caveat. Saying so now is cheaper than having a reviewer discover it. Co-Authored-By: Claude Opus 5 --- ...-17-adr008-multi-tenancy-research-index.md | 2 +- .../2026-08-17-adr008-plane-e-enforcement.md | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/research/2026-08-17-adr008-multi-tenancy-research-index.md b/research/2026-08-17-adr008-multi-tenancy-research-index.md index 1712b3d..81207f2 100644 --- a/research/2026-08-17-adr008-multi-tenancy-research-index.md +++ b/research/2026-08-17-adr008-multi-tenancy-research-index.md @@ -55,7 +55,7 @@ Ranked by how badly they mislead. | # | Finding | Effect | |---|---|---| -| O1 | **pgTAP + pgrls** (67 RLS lint rules, semantic policy-diff for CI gating, pytest isolation plugin) exist off the shelf. `rapp-postgres` already runs a 15-probe harness of the same shape. | The E3 half of open question §18.3 is far cheaper than assumed. Splits the question into a tractable mechanical half and a human half. | +| O1 | **pgTAP + pgrls** exist off the shelf; `rapp-postgres` already runs a 15-probe harness of the same shape. pgrls **verified directly** 2026-08-17: MIT, 67 rules (54 security, incl. BYPASSRLS / LEAKPROOF / `SECURITY DEFINER` / cross-tenant leaks), 19 auto-fixable, Z3 isolation proofs, SAFE/BREAKING policy-diff for CI gating, pytest plugin, PG 15–17. **But it is a 26-star beta project** — adopt the approach with confidence, treat the dependency as a supply-chain decision. pgTAP is long-established and carries no such caveat. | The E3 half of open question §18.3 is far cheaper than assumed. Splits the question into a tractable mechanical half and a human half. | | O2 | **OWASP Multi-Tenant Security Cheat Sheet** is the closest external baseline to our framework and is referenced nowhere in the estate. | Adopt as the named external baseline in ADR §15. | | O3 | **OpenID AuthZEN Authorization API 1.0** reached Final Specification January 2026; Keycloak shipped experimental support May 2026. Demonstrated PDP interoperability across 5+ engines. | `flex-auth`'s bespoke `CheckRequest` is pre-standard. Propose an `A4` rung for standard-interface PDP delegation. | | O4 | **Token versioning** as a middle rung between cached claim and live re-query. | Cheap global invalidation without a hot-path lookup. | diff --git a/research/2026-08-17-adr008-plane-e-enforcement.md b/research/2026-08-17-adr008-plane-e-enforcement.md index 1968399..fa65b2b 100644 --- a/research/2026-08-17-adr008-plane-e-enforcement.md +++ b/research/2026-08-17-adr008-plane-e-enforcement.md @@ -143,10 +143,25 @@ expected. cross-tenant isolation tests are the only reliable guard" ([Blair Jordan — testing RLS policies with pgTAP](https://blair-devmode.medium.com/testing-row-level-security-rls-policies-in-postgresql-with-pgtap-a-supabase-example-b435c1852602), [MakerKit — database testing with pgTAP](https://makerkit.dev/docs/next-supabase-turbo/development/database-tests)). -- **pgrls** is a purpose-built static analyzer: 67 lint rules for tenant and - per-user row-scoping bugs, performance traps and hygiene, 20 auto-fixable, a - semantic policy-diff command for CI gating, and a pytest plugin for isolation - tests ([pgrls on GitHub](https://github.com/pgrls/pgrls)). +- **pgrls** is a purpose-built static analyzer, **verified directly 2026-08-17** + rather than taken from a search snippet + ([pgrls on GitHub](https://github.com/pgrls/pgrls)). Confirmed: MIT licence; + 67 rules across SEC (54), PERF (5), HYG (4) and VIEW (4); 19 auto-fixable; + Z3 SMT-solver verification of isolation properties; a policy-diff command + classifying changes SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS; a + `pgrls.testing` pytest plugin with role-switching and per-test transactions; + SARIF, JUnit XML and GitHub-annotation output; tested against PostgreSQL + 15–17. Its SEC rules cover exactly the traps in §3 — BYPASSRLS, LEAKPROOF, + `SECURITY DEFINER`, cross-tenant leaks. + + **Maturity caveat, stated because this digest recommends adoption.** It is + 26 stars, 2 forks, 527 commits, self-described "Beta — actively maintained". + The engineering is serious (Z3 proofs, a precision corpus) but this is a + small project, not an established dependency. Adopt the *approach* with + confidence; treat the *tool* as a supply-chain decision requiring the estate's + normal SBOM and pinning discipline, and be prepared to reimplement its + handful of load-bearing rules if it goes unmaintained. pgTAP, by contrast, is + long-established and carries no such caveat. The named common failures match ours exactly: "a forgotten role, a pool reusing connections with the wrong tenant context, or an admin path bypassing RLS From f09d3ef5d25ba4c0fdaa6b9887faa225b74d6d39 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 17 Aug 2026 14:20:33 +0200 Subject: [PATCH 2/7] research: record the structure-not-tooling stance and the retention design Operator decision: use research as design input, adopt tooling only where it is an established standard with broad application, otherwise build ground up so we can refactor freely. Settles two open recommendations. AuthZEN qualifies (OpenID Final Spec, multi-vendor interop) and stays as the proposed A4 rung; OWASP stays as a baseline; PostgreSQL RLS mechanics are facts, not tooling. pgrls and pgTAP are out as dependencies - we take pgrls's rule taxonomy as a checklist for probes we write into the existing isolation-test.sh harness, which already found four real defects and has no dependency tree. Its SAFE/BREAKING policy-diff idea is the structural contribution worth reimplementing. Also records that gap G1 was designed rather than deferred, and the finding that came out of it: per-consumer physical retention is not implementable on CNPG, so the erasure horizon is the instance maximum and retention becomes a placement trigger. Co-Authored-By: Claude Opus 5 --- ...-17-adr008-multi-tenancy-research-index.md | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/research/2026-08-17-adr008-multi-tenancy-research-index.md b/research/2026-08-17-adr008-multi-tenancy-research-index.md index 81207f2..9f50e8a 100644 --- a/research/2026-08-17-adr008-multi-tenancy-research-index.md +++ b/research/2026-08-17-adr008-multi-tenancy-research-index.md @@ -101,7 +101,75 @@ digest: 7. Route G7 to `tenant-engine` separately. It is a critique of ratified canon, not of this ADR, and folding it in would overreach. -## 7. Method and limits +## 7. Adoption stance — structure, not tooling (operator decision, 2026-08-17) + +Research output is used as **design input**, not as a shopping list. The +operator's rule: + +> Use the structure that supports, extends or challenges our design. Do not +> adopt the tooling unless it is an established industry standard with broad +> application. Otherwise build ground up, so we can optimize and refactor as we +> see fit. + +Applied to this survey: + +| Finding | Adopt? | How | +|---|---|---| +| OWASP Multi-Tenant Security Cheat Sheet | **Yes — baseline** | A published security baseline, not tooling. Cite as the external reference our ladders answer to. | +| OWASP API Security Top 10 (BOLA / API1) | **Yes — vocabulary** | Names our gap in terms a security reviewer already holds. | +| OpenID AuthZEN Authorization API 1.0 | **Yes — standard** | Final Specification from a standards body with demonstrated multi-vendor interop. Qualifies as broad and established. Propose as the `A4` rung. | +| Azure tenancy models / AWS SaaS Lens / cell architecture | **Yes — structure** | Taxonomies and patterns. Corroborate the spectrum reframe and the E↔P coupling. | +| PostgreSQL RLS mechanics (`FORCE`, `BYPASSRLS`, `SET LOCAL`, leakproof) | **Yes — facts** | Engine behaviour, not a vendor's product. | +| **pgrls** | **No — structure only** | Take its rule *taxonomy* (54 security rules over RLS enablement, BYPASSRLS, LEAKPROOF, `SECURITY DEFINER`, cross-tenant leak; the SAFE/BREAKING/REQUIRES_REVIEW/DANGEROUS policy-diff classification) as a checklist for probes we write ourselves. 26 stars and beta was already a supply-chain caveat; the stance settles it. | +| **pgTAP** | **No** | Established in the Postgres world but still tooling, and we already have the ground-up equivalent: `scripts/isolation-test.sh`, 15 probes, bash + psql, no dependency tree. Extend that harness to the tenant boundary. | + +The practical consequence is small and good: `rapp-postgres` already owns a +probe harness that found four real defects in its own provisioning SQL. The E2 +and E3 evidence artifacts become new probes in an existing tool, informed by +pgrls's taxonomy of what to look for, with no new dependency. The +policy-diff idea — classifying a policy change as safe or breaking before it +lands — is the genuinely novel structural contribution worth reimplementing. + +## 8. Retention and erasure — designed, not just flagged + +Gap G1 (tenant offboarding and deletion) has been taken up rather than left as +an open question. See `rapp-postgres/docs/adr/ADR-0002-data-retention-and-erasure.md`. + +The shape, per operator direction: retention is declared per consumer as +`N` days defaulting to 30, `rapp-postgres` provides the abstract mechanism for +any dataset, and a consumer or its governance layer builds policy on top of +that interface. + +The finding that emerged while designing it: **per-consumer physical retention +is not implementable.** CNPG has one WAL stream and one window, so the instance +retention is derived as the maximum across consumers and every consumer's +erasure horizon is that maximum. A consumer declaring 7 days beside one +declaring 90 gets 90. This is the retention analogue of ADR-0001 §5 — state the +coupling rather than imply an isolation that is not there — and it produces two +things the estate could not previously say: + +- The **erasure horizon**: the interval between deleting data and it ceasing to + be recoverable from anything the platform holds. Deleted rows survive in + backups for the whole window; that is the difference between "deleted" and + "erased", and it had never been written down. +- **Retention as a placement trigger.** A consumer needing a shorter horizon + than the instance floor cannot have one at P1. It moves to a dedicated + cluster for a reason unrelated to performance — which is exactly why it needs + recording, since nobody looks for a retention argument when reviewing + placement. + +Deletion splits mechanism from policy: the platform sweeps whole datasets and +records an opaque `deletionPolicyRef` it never interprets, so every deletion +traces to the policy that authorised it. Rows are not a dataset. +`consumerDatabase` is deliberately not sweepable. + +**Open for draft-3:** whether this is a fifth plane (`R` — retention and +erasure, with its own ladder from "no policy" through "verified erasure within +a published horizon") or a lifecycle stage cutting across the existing four. +The interface is ladder-shaped, which argues for a plane — at the cost of the +framework's name. + +## 9. Method and limits Four search-and-read passes, one per plane, against vendor architecture guidance (AWS Well-Architected SaaS Lens, Azure Architecture Center), standards From d0b57b1a501cbe8b605cb9e02db6c13fce89d27f Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 17 Aug 2026 14:33:50 +0200 Subject: [PATCH 3/7] ADR-008 draft-3: corrections, plane R, and a name Renamed to Tenancy Posture. Four Planes stopped being true and a name that encodes a count breaks every time the framework grows; the posture vector is the durable centre. Corrections from the research digests: - E3 no longer claims the application cannot route around it. Any session can re-SET the GUC, so E3 is decisive against a forgotten predicate and useless against SQL injection or a compromised process. Replaced the claim with a threat-model table. Draft-2 was overclaiming in exactly the way section 6 prohibits, which is the correction I most wanted made before review. - E3 layers on E2 rather than replacing it; claiming E3 now requires the E2 artifact too, because E3 alone fails open under injection. - The GUC is set transaction-locally, not at pool checkout, and the scaling section's pooling claim was backwards: SET LOCAL is what makes E3 safe under a pooler; statement-level pooling is what leaks. - Placement is a default plus per-tenant exceptions. One P level per service could not express the isolation tiers section 11 already sells. Added plane R for retention and erasure, implementing the rapp-postgres ADR-0002 interface: N-day retention defaulting to 30, a published erasure horizon, and dataset sweeps whose policy the consumer owns. R exists because deleting a row does not remove it from yesterday's backup, and the estate had never written that down. Also: A4 for a standard PDP interface now that AuthZEN is Final; evidence split into mechanical and adversarial, since a green CI run is not E2 evidence; the tenant boundary named as OWASP API1; and section 14 recording the structure-not-tooling stance. Co-Authored-By: Claude Opus 5 --- .../adr-008-multi-tenancy-model.md | 816 ++++++++++-------- 1 file changed, 460 insertions(+), 356 deletions(-) diff --git a/canon/architecture/adr-008-multi-tenancy-model.md b/canon/architecture/adr-008-multi-tenancy-model.md index 91c6e55..15bdc3f 100644 --- a/canon/architecture/adr-008-multi-tenancy-model.md +++ b/canon/architecture/adr-008-multi-tenancy-model.md @@ -1,29 +1,30 @@ --- id: ADR-008 type: architecture-decision-record -title: "Multi-Tenancy Framework: Four Planes, Graduated Levels, Declared Posture" +title: "Tenancy Posture: Five Planes, Graduated Levels, Declared Conformance" status: proposed decided_by: Bernd Worsch date: "2026-08-17" -revision: "draft-2" -tags: ["architecture", "multi-tenancy", "isolation", "placement", "maturity", "tenant-engine", "flex-auth", "rapp-postgres", "scaling"] +revision: "draft-3" +tags: ["architecture", "multi-tenancy", "isolation", "placement", "retention", "maturity", "tenant-engine", "flex-auth", "rapp-postgres", "scaling"] --- -# ADR-008: Multi-Tenancy Framework — Four Planes, Graduated Levels, Declared Posture +# ADR-008: Tenancy Posture — Five Planes, Graduated Levels, Declared Conformance ## Status -**Proposed, draft-2.** Draft-1 (2026-08-17) proposed a single model with fixed -characteristics. It was rejected on the grounds that the estate does not need -one model of multi-tenancy — it needs a framework able to describe, hold, and -improve several situations at once, including repos that do not separate -tenants rigorously today and must be developed toward doing so. Draft-2 is -that reframe. +**Proposed, draft-3.** -This document decides things currently owned by `tenant-engine`, `flex-auth`, -`rapp-postgres`, `adaptive-pricing`, and `railiance-platform`. It must be -reviewed by those owners before ratification; §18 lists what each is being -asked to accept. +- **draft-1** proposed a single model with fixed characteristics. Rejected: it + could not describe a repo that is not there yet. +- **draft-2** reframed to graduated levels per plane. Externally corroborated + (§16), but four of its statements were wrong and one thing it needed was + missing. +- **draft-3** applies those corrections, adds the retention plane, and records + an adoption stance. It is informed by four external research digests, one per + original plane, in `research/2026-08-17-adr008-*`. + +Reviewed by nobody yet. §19 lists what each owner is being asked to accept. ## 1. Context @@ -35,55 +36,43 @@ down what it is building. Five documents each cover a slice: | `iam-profile_v0.3` (NetKingdom) | Tenant identifier shape, `tenant_roles` claim, staleness rules | Ratified | | `tenant-engine-boundary-contract_v0.1` (NetKingdom) | Who owns tenant records, roles, plan assignment | Ratified | | `business-app-service-contract_v0.1` §1 (Custodian) | Business apps: instance-per-client, tenant-keyed data | Ratified | -| `rapp-postgres/docs/adr/ADR-0001` | Consumer + tenant isolation in PostgreSQL | Proposed, governs one repo | -| `rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1` | The stacked-boundary gap | Routed 2026-08-10, **still unratified** | +| `rapp-postgres` ADR-0001 | Consumer + tenant isolation in PostgreSQL | Proposed, governs one repo | +| `rapp-postgres` ADR-0002 | Per-consumer retention and the erasure horizon | Proposed, governs one repo | +| `shared-platform-relational-storage_v0.1` | The stacked-boundary gap | Routed 2026-08-10, **still unratified** | Four failures follow. -**The gap has been diagnosed once and the fix stalled.** The -shared-platform-relational-storage draft was written to fill this hole. It has -sat unratified for a week and appears in neither canon directory. §19 attaches -a ratification path so this one does not join it. +**The gap was diagnosed once and the fix stalled.** The v0.1 draft was written +to fill this hole and has sat unratified in neither canon directory. §20 +attaches a ratification path so this one does not join it. **Placement is owned by nobody.** `user-engine-pg` and `target-revenue-pg` are dedicated; `apps-pg`, `net-kingdom-pg`, `platform-pg`, `state-hub-db` and -`forgejo-db` are shared. Both patterns are live, neither is written down, and -each new service copies whichever neighbour it consulted. `tenant-engine` +`forgejo-db` are shared. Both live, neither written down. `tenant-engine` raised this with `railiance-platform` on 2026-08-16; unanswered. -**Two contradictory tenancy defaults are already ratified.** -`business-app-service-contract` §1.2 makes instance-per-client the default for -business apps. Platform services pool all tenants into one database. Nothing -states which shape a new service takes, and no definition distinguishes the -two categories. +**Two contradictory defaults are already ratified.** Business apps get +instance-per-client; platform services pool. Nothing says which shape a new +service takes, and no definition separates the categories. -**There is no honest way to describe a repo that is not there yet.** This is -the failure draft-1 missed. The estate absorbs and will keep absorbing repos -with weak or absent tenant separation — app-local accounts, no tenant column, -ad-hoc filtering. Today such a repo is simply non-conformant, which gives it -two bad options: misrepresent its posture, or stay outside the framework -entirely. Both are worse than a framework that can say "this repo is at level -1, its target is level 3, here is the gap and the evidence." +**There is no honest way to describe a repo that is not there yet.** The estate +absorbs repos with weak or absent tenant separation. Today such a repo is +simply non-conformant, leaving it two bad options: misrepresent its posture, or +stay outside the framework. ## 2. What this document is -**A framework, not a model.** It does not specify one correct multi-tenancy -implementation with a fixed set of characteristics. It supplies: - -- **Terminology** — four planes, and graduated levels within each (§3, §4). -- **A declaration** — the posture vector, so any repo can state where it - actually is (§5). -- **A conformance rule** — accuracy, not altitude (§6). -- **Methodology** — analyze, establish, improve, guard (§12). -- **Evidence definitions** — what proves a claimed level (§13). +**A framework, not a model.** It specifies no single correct implementation. It +supplies terminology (§3, §4), a declaration (§5), a conformance rule (§6), +methodology (§12), and evidence definitions (§13). A service is conformant when its declared posture is accurate and its -trajectory is recorded. A service is non-conformant when it claims a level it +trajectory recorded. A service is non-conformant when it claims a level it cannot evidence — regardless of how high or low that level is. -## 3. Four orthogonal planes +## 3. Five orthogonal planes -"Is this multi-tenant?" is treated as one question. It is four, and they are +"Is this multi-tenant?" is treated as one question. It is five, and they are independent: | Plane | Question | Vocabulary owner | @@ -91,218 +80,294 @@ independent: | **Identity (I)** | How is a tenant named and validated? | `tenant-engine` / IAM Profile | | **Authorization (A)** | How is a request bound to the tenants it may act for? | `flex-auth` | | **Enforcement (E)** | Where, mechanically, is the tenant boundary enforced? | This framework | -| **Placement (P)** | Which physical substrate holds a tenant's data? | `railiance-platform` | +| **Placement (P)** | Which substrate holds a tenant's data? | `railiance-platform` | +| **Retention (R)** | How long does data persist, and how is it erased? | The storage platform; policy by the consumer | -Treating them as one produces concrete errors today. `rapp-postgres`'s -`PostgresConsumer` carries a field `tenantIsolation: consumer-service-boundary` -— an **E**-plane fact recorded in a **P**-plane artifact, reading as though the -storage platform enforces something it does not. The "dedicated versus shared" -argument routinely mixes P (capacity, blast radius) with E (correctness). +Conflation produces errors today. `rapp-postgres`'s `PostgresConsumer` carries +`tenantIsolation: consumer-service-boundary` — an **E**-plane fact in a +**P**-plane artifact, reading as though storage enforces something it does not. +The "dedicated versus shared" argument mixes P (capacity, blast radius) with E +(correctness). -The planes are separated **precisely so that each may sit at a different -level**. A repo can be strong on identity and weak on enforcement, or the -reverse, and the framework must state that without flattening it to a single -verdict. That is the whole point, and draft-1 lost it by collapsing E into a -single fixed rule. +The planes are separated *precisely so each may sit at a different level*. -**Decision 3.1:** every document, declaration, and plan tier that says +**Decision 3.1:** every document, declaration and plan tier that says "isolation" MUST name which plane it means. -**Decision 3.2:** the planes couple at their top levels — `E4` is only -reachable at `P3` or above — and that coupling MUST be stated where it applies -rather than used to argue the planes are one. +**Decision 3.2:** the planes couple at their tops and the couplings MUST be +stated where they apply, not used to argue the planes are one: + +- `E4` is reachable only at `P3` or above. +- `R`'s erasure horizon is bounded below by `P` — on shared substrate, a + consumer's horizon is the instance maximum (§4.5). + +**Decision 3.3 — scope.** The P and R ladders describe a service's **primary +datastore**. Caches, search indices, message queues and background jobs are +named leak surfaces in the external baselines and are assessed separately, not +covered by a posture vector. Saying so is honest; implying the vector covers +them would not be. ## 4. Graduated levels -Each plane carries an ordered ladder. Higher is stronger, not better: the -right level is the one a service can evidence and its risk warrants. +Each plane carries an ordered ladder. Higher is stronger, not better: the right +level is the one a service can evidence and its risk warrants. -### 4.1 Identity (I) — how the tenant is named +### 4.1 Identity (I) | Level | State | |---|---| -| **I0** | No tenant concept. Data is not attributable to a tenant. | -| **I1** | A local tenant notion exists but does not conform to `tenant::`. | -| **I2** | Canonical identifiers used, validated against the ratified vocabulary; `tenant-engine` is the source of existence. | -| **I3** | I2 plus capability roles (`PLTF`/`IAM`/`VEN`/`CUS`) honoured, with live `tenant-engine` re-query for privileged, destructive, credential-vending or `aal2`-class decisions rather than trusting the cached `tenant_roles` claim. | +| **I0** | No tenant concept. Data not attributable to a tenant. | +| **I1** | A local tenant notion exists but is not canonical, **or** the tenant is taken from the request rather than from a verified token. | +| **I2** | Canonical identifiers, bound at the identity provider and carried as a verified claim; `tenant-engine` is the source of existence. | +| **I3** | I2 plus capability roles honoured, with live `tenant-engine` re-query for privileged, destructive, credential-vending or `aal2`-class decisions. | -Note `business-app-service-contract` §2.1 sets app-local accounts as the v1 -baseline for business apps — an explicitly sanctioned low level with recorded -triggers for moving up. That is the pattern this framework generalises. +I1 now explicitly absorbs request-supplied tenant identifiers. "Never trust +client-supplied tenant IDs without validation" is a named anti-pattern; a +service reading the tenant from a header is at I1 however canonical the string. -### 4.2 Authorization (A) — how a request is bound to a tenant +`business-app-service-contract` §2.1 sets app-local accounts as the v1 baseline +for business apps — a sanctioned low level with recorded triggers for moving +up. That is the pattern this framework generalises. + +### 4.2 Authorization (A) | Level | State | |---|---| -| **A0** | No authorization, or tenant context not carried on the request. | +| **A0** | No authorization, or tenant context not carried. | | **A1** | Ad-hoc checks scattered through handlers. | | **A2** | A single local authorization boundary; tenant context bound once, centrally. | | **A3** | Decisions delegated to `flex-auth` as PDP, with live re-query where the IAM Profile requires it. | +| **A4** | A3 over a **standard** PDP interface (OpenID AuthZEN Authorization API 1.0), so the decision point is swappable and the enforcement point is not coupled to one engine's request shape. | -### 4.3 Enforcement (E) — where the tenant boundary actually holds +A4 is new. `flex-auth` uses a bespoke `CheckRequest` and a bespoke action +vocabulary, with action strings copied verbatim between repos to avoid +re-derivation — exactly the coupling AuthZEN removes. The specification reached +Final in January 2026 and Keycloak shipped experimental support in May. We are +not wrong, we are pre-standard, and the ladder should have somewhere to go. -This is the reframed core. Draft-1 asserted one rule for everyone; this is a -ladder whose top is platform-enforced. +**Internal service-to-service calls are in scope for this plane.** "Skipping +tenant validation for internal services" is a named anti-pattern, and our +estate is mostly internal calls — `flex-auth` calls `tenant-engine` +synchronously on the authorization path. A service identity acting on behalf of +a tenant must carry and revalidate tenant context to claim A2 or above. -| Level | Mechanism | Bypassed by | -|---|---|---| -| **E0** | None. Data not tenant-keyed; separation is incidental or absent. | Anything. | -| **E1** | Data tenant-keyed, filtering applied per query at call sites. | One missing predicate. | -| **E2** | Filtering centralised at a single service-side choke point that binds authenticated identity to permitted tenants. | Code that bypasses the choke point. | -| **E3** | Platform-assisted: row-level security keyed on a session GUC set at pool checkout, or an equivalent data-access layer the application cannot trivially route around. | A `SECURITY DEFINER` function, a missing policy, or a wrong GUC. | -| **E4** | Structural: the connection a workload holds cannot address another tenant's data at all. Requires per-tenant credentials and per-tenant substrate. | Only a platform-level compromise. | +### 4.3 Enforcement (E) -Three properties of this ladder matter. +| Level | Mechanism | +|---|---| +| **E0** | None. Data not tenant-keyed; separation incidental or absent. | +| **E1** | Data tenant-keyed, filtering applied per query at call sites. | +| **E2** | Filtering centralised at a single service-side choke point binding authenticated identity to permitted tenants. | +| **E3** | E2 **plus** platform-assisted filtering: row-level security keyed on a tenant GUC set transaction-locally, or an equivalent enforced data-access layer. | +| **E4** | Structural: the credential a workload holds cannot address another tenant's data at all. Requires per-tenant credentials and per-tenant substrate. | -**E3 is real and is not forbidden.** Draft-1 rejected RLS on the grounds that -a control depending on the consumer setting a session variable "looks like a -database guarantee without being one". The observation is correct; the -conclusion was wrong. E3 is materially stronger than E2 — it converts "every -call site must remember" into "one checkout path must be right" — and it is -the standard mitigation in pool-model systems. What was actually wrong was -*calling E3 by E4's name*. The honesty requirement survives; the prohibition -does not. +**Correction from draft-2.** Draft-2 described E3 as something "the application +cannot trivially route around". That is false and it was this document +overclaiming in exactly the way §6 prohibits. Any session can re-issue `SET` on +a custom GUC, so an attacker with SQL execution can reset the tenant and read +across the boundary. What E3 buys is precise, and the ladder must say so: -**Higher enforcement is the direction of travel, not an optional extra.** -Services holding data whose cross-tenant exposure would be a reportable breach -SHOULD target E3 or above. The framework's default expectation for a new -platform service is **E2 at first serve, E3 recorded as target**. +| Threat | E1 | E2 | E3 | E4 | +|---|:--:|:--:|:--:|:--:| +| A developer forgets a tenant predicate | ✗ | ✓ | ✓ | ✓ | +| A new code path bypasses the choke point | ✗ | ✗ | ✓ | ✓ | +| SQL injection reaching the connection | ✗ | ✗ | ✗ | ✓ | +| The application process is compromised | ✗ | ✗ | ✗ | ✓ | -**Platform enforcement is a platform obligation, not only a consumer one.** -Draft-1 placed the whole burden on consumers permanently. It is now stated -that reaching E3 requires the storage platform to *offer* the mechanism — -provisioned RLS policies, a documented GUC contract, and a probe. -`rapp-postgres` ADR-0001 §4 already commits to provisioning it on request; -that offer becomes an obligation with a defined contract (§18.6). Where a -consumer wants E3 and the platform has not supplied the mechanism, the gap is -the platform's, not the consumer's. +E3 is a strong control against **accident** — the common case, and the one that +causes real breaches — and no control at all against **compromise**. Only E4 +holds against both, because the credential itself cannot address another +tenant's data. -### 4.4 Placement (P) — which substrate holds the data +**Correction: E3 layers on E2, it does not replace it.** External practice +treats application-layer and database-layer filtering as complementary. A +service that dropped its choke point on reaching E3 would be *worse* off, since +E3 fails open under injection. Claiming E3 therefore requires the E2 evidence +artifact as well. -Draft-1's `R0` ("shared tables, tenant column") is removed: that was an -E-plane state mislabelled as placement. Corrected ladder: +**Correction: the GUC is set transaction-locally.** Draft-2 said "at pool +checkout", which is session scope and the wrong instrument. Under a pooler in +statement mode, `SET` leaks between clients and returns other tenants' rows — +a failure that appears only under production concurrency and produces no error. +Use `SET LOCAL` inside an explicit transaction. + +**Platform enforcement is a platform obligation.** Reaching E3 requires the +storage platform to *offer* the mechanism: provisioned policies, a documented +GUC contract, and a probe. Where a consumer wants E3 and the platform has not +supplied it, the gap is the platform's. §19.6 asks `rapp-postgres` to define +that contract, which must carry `FORCE ROW LEVEL SECURITY` on every tenant +table (without it the table owner bypasses policies silently, and ADR-0001 +already established that our migration role owns the tables it creates), no +`BYPASSRLS` on leased roles, `SECURITY INVOKER` for ordinary logic, and an +`EXPLAIN` comparison because RLS disables functional indexes built on +non-leakproof functions. + +**Default expectation** for a new platform service: E2 at first serve, E3 +recorded as target. Services whose cross-tenant exposure would be a reportable +breach SHOULD target E3 or above. + +### 4.4 Placement (P) | Level | Shape | Live occupants | |---|---|---| -| **P0** | Shares a database with another consumer (schema or tables co-resident). | None sanctioned; the state absorbed repos often arrive in. | +| **P0** | Shares a database with another consumer. | None sanctioned; the state absorbed repos arrive in. | | **P1** | Database per consumer, shared cluster. | `audit-core`, `tenant-engine` on `platform-pg` | | **P2** | Dedicated cluster per consumer. | `user-engine-pg`, `target-revenue-pg` | | **P3** | Dedicated cluster per tenant. | Business apps per `business-app-service-contract` §1.2 | | **P4** | P3 plus separate region or jurisdiction. | None | **P0 → P1 → P2 does not raise the E level.** Those steps buy consumer -isolation, capacity predictability, independent backup retention, and a -smaller operational blast radius. They leave the tenant boundary exactly where -the E level puts it. Only P3 makes E4 reachable. This is the single most -misusable fact in the framework and §11 governs how it may be described -commercially. +isolation, capacity predictability, independent retention and a smaller +operational blast radius. Only P3 makes E4 reachable. This is the most +misusable fact in the framework and §11 governs how it may be described. -**Decision 4.5:** P1 is the default for platform services; P3 is the default -for client-facing business apps, as already ratified. A service unsure which -it is must resolve that before choosing (§18.4). +**Decision 4.4.1:** P1 is the default for platform services; P3 for +client-facing business apps, as already ratified. A service unsure which it is +must resolve that first (§19.4). + +**Decision 4.4.2 — placement scopes to data substrate.** Identity-provider +placement (realm-per-tenant versus Organizations) is the same silo/pool +decision on a different substrate, is live in our estate, and is undecided. +Realm-per-tenant carries a stated ceiling around 5–20 tenants, far below our +target. Recorded here as a parallel question (§19.7), not folded into P. + +### 4.5 Retention and erasure (R) + +New in draft-3. Implemented abstractly by the storage platform for any dataset; +policy is built on top of that interface by the consumer or its governance +layer. Reference implementation: `rapp-postgres` ADR-0002. + +| Level | State | +|---|---| +| **R0** | No retention or deletion position. Data kept indefinitely by default; no deletion path exists. | +| **R1** | Platform default retention applies (N=30 days). The consumer has declared no requirement. | +| **R2** | Retention declared as N days per dataset; the **erasure horizon** is published, and the consumer makes no promise shorter than it. | +| **R3** | Policy-driven deletion: the consumer or its governance layer declares what is due, the platform sweeps whole datasets on that instruction and evidences each run. | +| **R4** | Verified erasure: deletion proven complete across live data, backups and derived copies within the published horizon. | + +Three properties. + +**The erasure horizon is the interval between deleting data and it ceasing to +be recoverable from anything the platform holds.** Deleting a row does not +remove it from yesterday's backup. With an N-day window, deleted data remains +recoverable for N days. That is the difference between "deleted" and "erased" +and the estate had never written it down. + +**On shared substrate, retention is not per-consumer.** Physical backup is +instance-wide — one WAL stream, one window — so the instance retention is +*derived* as the maximum across co-resident consumers, and every consumer's +horizon is that maximum. A consumer declaring 7 days beside one declaring 90 +gets 90. This is the retention analogue of ADR-0001's blast-radius disclosure: +state the coupling rather than imply an isolation that is not there. + +**Retention is therefore a placement trigger.** A consumer needing a horizon +shorter than the instance floor cannot have one at P1. It moves to P2 for a +reason with nothing to do with performance — which is exactly why it needs +recording, since nobody looks for a retention argument when reviewing +placement. + +Deletion splits mechanism from policy. The platform deletes whole **datasets** +on instruction and records an opaque policy reference it never interprets, so +every deletion traces to what authorised it. Rows are not a dataset: row expiry +is the consumer's own DML under its migration lease. Dropping a consumer's +whole database is an operator-gated offboarding step, never a scheduled one. ## 5. The posture vector -A service states its tenancy posture as one level per plane, plus a target and -a date: +A service states one level per plane, plus a target, a date, and any placement +exceptions: ```yaml tenancy: - current: { I: 2, A: 3, E: 2, P: 1 } - target: { I: 2, A: 3, E: 3, P: 1 } + current: { I: 2, A: 3, E: 2, P: 1, R: 1 } + target: { I: 2, A: 3, E: 3, P: 1, R: 2 } reviewed: "2026-08-17" gap: - E: "Choke point exists and is tested; RLS not yet provisioned. Blocked on - rapp-postgres offering the GUC contract. Target Q4." + E: "Choke point exists and is tested; RLS not provisioned. Blocked on + rapp-postgres publishing the GUC contract. Target Q4." + R: "Retention declared; erasure horizon not yet published to consumers." ``` -Worked examples, as best assessed today and subject to owner correction: +**Placement exceptions.** Draft-2 assigned one P level per service, which +cannot express the vertically partitioned model — most tenants pooled, some +dedicated — that §11's isolation tiers require. A tier requiring `P2` bought by +three tenants would put the service at two levels at once, forcing an over- or +under-claim. Placement is therefore declared as a default plus exceptions: + +```yaml + placement_exceptions: + - tenants: ["tenant:enterprise:*"] + P: 3 + reason: "isolation tier; see adaptive-pricing tier definition" +``` + +A service with exceptions must be able to say which tenants are on which +substrate. That mapping is a first-class artifact, not archaeology. + +Worked examples, best-effort and subject to owner correction: | Service | Current | Notes | |---|---|---| -| `tenant-engine` | `I2 A3 E2 P1` | Moving to P1 under TEN-WP-0009; E2 via its own service boundary. | -| `audit-core` | `I2 A3 E2 P1` | Same shape; holds audit data, so E3 is the more urgent target. | -| A newly absorbed repo | `I1 A1 E1 P0` | Conformant **if declared**, with a recorded path. | - -The vector is what makes the framework hold both situations honestly. A repo -at `I1 A1 E1 P0` is not a failure — it is a described state with a direction. -A repo claiming `E3` with no RLS policy is a failure regardless of how good the -rest of it is. +| `tenant-engine` | `I2 A3 E2 P1 R1` | Moving to P1 under TEN-WP-0009; retention declared, horizon not yet published. | +| `audit-core` | `I2 A3 E2 P1 R1` | Holds audit evidence, so both E3 and R2 are urgent targets. | +| A newly absorbed repo | `I1 A1 E1 P0 R0` | Conformant **if declared**, with a recorded path. | **Decision 5.1:** the posture vector is declared in the repo, not in the hub, consistent with local-files-are-source-of-truth. -## 6. Honest absorption — conformance is accuracy, not altitude - -The governing rule of this framework: +## 6. Conformance is accuracy, not altitude > **A service is conformant when its declared posture is accurate, its target > is recorded, and it does not claim a level it cannot evidence. It is > non-conformant when it overclaims — at any altitude.** -Consequences, stated deliberately: - - Declaring `E0` is conformant. Concealing `E0` is not. - A repo may be absorbed at any posture. It may not be absorbed silently. -- No service is blocked from the estate for being low on a ladder. Services - MAY be blocked from *specific work* — serving a tenant grouping, holding a - data class, carrying a plan tier — by requirements expressed as minimum - levels (§11). -- Downgrading is permitted and must be declared. A regression discovered by - guarding (§12) is a defect; a regression declared in advance is a decision. +- No service is blocked from the estate for being low on a ladder. Services MAY + be blocked from *specific work* — serving a tenant grouping, holding a data + class, carrying a plan tier — by requirements expressed as minimum levels. +- Downgrading is permitted and must be declared. A regression found by guarding + is a defect; a regression declared in advance is a decision. -This is what the plane separation is *for*. Without it, "not rigorous about -tenant separation" is a single verdict that a repo either passes or fails. -With it, the same repo is `I1 A1 E1 P0` with a path — which is a plan, not an -indictment. +Without the plane separation, "not rigorous about tenant separation" is one +verdict a repo passes or fails. With it, the same repo is `I1 A1 E1 P0 R0` with +a path — a plan, not an indictment. ## 7. Portability across placement levels -Movement between P levels must be an operational change, not a rebuild. -Adopted from `tenant-engine`'s TEN-WP-0009 design: +Movement between P levels must be operational, not a rebuild: -- Connect by injected credential only — no cluster, host, namespace, or - database name in source. -- Own a whole database, never tables inside someone else's. Sharing a cluster - is reversible; sharing a database makes relocation a schema merge. +- Connect by injected credential only — no cluster, host, namespace or database + name in source. +- Own a whole database, never tables inside someone else's. - Idempotent schema creation. - No cross-database joins or co-location assumptions. -A service meeting these moves P1 → P2 by: create target, dump, restore, swap -the credential reference, restart. - -**Decision 7.1:** mandatory at P1 and above. At P3, SHOULD rather than MUST — -the consolidation `business-app-service-contract` §1.3 preserves is a real -benefit, but a per-client instance that never moves is not misconformant for -naming its own database. +**Decision 7.1:** mandatory at P1 and above. At P3, SHOULD rather than MUST — a +per-client instance that never moves is not misconformant for naming its own +database. ## 8. Placement triggers -A level change decided under pressure is decided badly. Each service records -at provisioning time what would move it: - -- Noisy neighbour on a latency-critical path -- A compliance or residency requirement -- A plan tier requiring a higher minimum (§11) -- Backup retention or RTO no longer fitting -- Connection or memory ceiling reached (§16) +Recorded at provisioning time: noisy neighbour on a latency-critical path; a +compliance or residency requirement; a plan tier requiring a higher minimum; an +erasure horizon that no longer fits (§4.5); connection or memory ceiling +reached. **Decision 8.1:** triggers MUST be *monitored*, not merely recorded. A trigger -in a YAML comment that nobody re-reads is documentation, not control. The -`platform-pg` ceiling in §16 is the first that requires an actual alert. +in a YAML comment nobody re-reads is documentation, not control. **Decision 8.2:** placement policy ownership is proposed to -`railiance-platform`. This framework supplies the ladder, the triggers, and -the declaration; it does not appoint per-workload placement. See §18.2 — this -remains their call and their open request. +`railiance-platform`, **co-signed by `adaptive-pricing`**. Tenancy model +selection is a commercial decision as much as a technical one; an +operations-shaped repo should not hold it alone. ## 9. Credentials as a tenancy control -Short-lived leased credentials from OpenBao, re-read at connection checkout, -with overlap-first rotation, bound the residual risk at every E level below -E4: a leaked credential expires rather than persisting. This is stronger than -the industry norm of a long-lived per-service secret and belongs in the -tenancy model, not only in credential-management canon. +Short-lived leased credentials re-read at connection checkout, with +overlap-first rotation, bound the residual risk at every E level below E4: a +leaked credential expires rather than persisting. Stronger than the industry +norm of a long-lived per-service secret. **Decision 9.1:** static long-lived database credentials are not a sanctioned path for any service above E0. @@ -311,159 +376,181 @@ path for any service above E0. **Decision 10.1:** every platform holding consumer data MUST publish, in concrete terms, what a leaked runtime credential can and cannot reach at the -levels it operates. `rapp-postgres` ADR-0001 §5 is the reference -implementation. Where the model cannot provide a guarantee, the platform says -so and names the escalation, rather than describing a weaker control in -stronger language. +levels it operates. `rapp-postgres` ADR-0001 §5 is the reference. Where the +model cannot provide a guarantee, the platform says so and names the +escalation. -## 11. Commercial expression — room to be successful +## 11. Commercial expression -Isolation will be sold. The framework must let commercial and presentation -work rather than obstruct them, while preventing a claim that cannot be -evidenced. - -**Decision 11.1:** plan tiers are expressed internally as **minimum levels**, -not as implementation labels. A tier may require `E3 P2`; it need not print -that string anywhere customer-facing. - -**Decision 11.2:** marketing and product language is free. There is no -requirement to expose level labels, ladder names, or this document to -customers. "Dedicated infrastructure", "isolated tenancy", "private instance" -all remain available. - -**Decision 11.3:** the constraint is on **evidence, not vocabulary**. Any -customer-facing isolation or availability claim must map to a minimum level -the delivering service actually holds, recorded once when the tier is defined. -The review is internal and happens at tier definition — not per campaign, per -page, or per conversation. - -**Decision 11.4:** the one hard line — a claim that another tenant *cannot* -reach the customer's data requires **E4**. Everything softer than that -absolute has room. This is the claim that reaches compliance questionnaires -and contracts, and it is the one that must be true. - -`adaptive-pricing` owns plan terms; `tenant-engine` owns which plan a tenant -holds. Neither currently maps a tier to minimum levels; §18.5 asks them to, -for tiers making isolation or availability claims only. +- **11.1** Plan tiers are expressed *internally* as minimum levels. A tier may + require `E3 P2 R2`; it need not print that anywhere customer-facing. +- **11.2** Marketing and product language is free. No requirement to expose + level labels or this document. "Dedicated infrastructure", "isolated + tenancy", "private instance" all remain available. +- **11.3** The constraint is on **evidence, not vocabulary**. A customer-facing + isolation, availability or retention claim must map to a minimum level the + delivering service actually holds, recorded once when the tier is defined. + The review is internal and happens at tier definition — not per campaign. +- **11.4** Two hard lines, because these reach contracts and compliance + questionnaires: + - A claim that another tenant **cannot** reach the customer's data requires + **E4**. + - A claim that deleted data **is gone** requires **R4**, or an erasure + horizon disclosed alongside it. ## 12. Methodology — analyze, establish, improve, guard -The framework is operated as a cycle, not consulted as a reference. +**Analyze.** Assess a repo against the ladders; produce `tenancy.current` with +reasoning recorded. Applies to new and absorbed services alike. -**Analyze.** Assess a repo against the four ladders and produce its current -vector. Output: the `tenancy.current` block with the reasoning recorded. -Applies to new services and absorbed ones alike. +**Establish.** Declare the target and gap. The target is set by data class, +tenant groupings served and plan tiers carried — not by ambition. -**Establish.** Declare the target vector and the gap. The target is set by the -data class held, the tenant groupings served, and any plan tier carried — not -by ambition. Output: `tenancy.target` plus a recorded gap per plane. +**Improve.** Move one plane at a time. Raising P while leaving E untouched is +the characteristic misstep. -**Improve.** Move one plane at a time. Cross-plane moves are where mistakes -happen — raising P while leaving E untouched is exactly the misstep §4.4 -warns about. Each step is ordinary repo work with ordinary evidence. - -**Guard.** Verify continuously that the declared posture still holds. Guarding -checks a service **against its own declaration**, not against a universal -maximum. This is what makes the verification problem tractable: nobody has to -prove every service is at E4; the check is that nobody is below what they -declared, and nobody claims what they cannot evidence. +**Guard.** Verify continuously that the declared posture holds — **against the +service's own declaration**, not a universal maximum. Nobody must prove every +service is at E4; the check is that none is below what it declared. Regression found by guarding is a defect; regression declared in advance is a -decision. The estate has been bitten twice by silent pin rollbacks that -produced ordinary-looking 403s and 404s rather than errors. Posture regression -will look the same, so guarding must be designed for invisible failure rather -than for crashes. +decision. The estate has been bitten twice by silent pin rollbacks producing +ordinary-looking 403s and 404s rather than errors. Posture regression looks the +same — an RLS context leak returns correct-looking rows for the wrong tenant. +Guarding must be designed for invisible failure, not for crashes. ## 13. Evidence per level -Guarding requires each level to have a defined evidence artifact. Proposed: - -| Level | Evidence | -|---|---| -| **I2** | Identifiers validated against the ratified vocabulary; rejection test for a malformed tenant id. | -| **I3** | Live re-query demonstrated on an `aal2`-class path; cached-claim path shown not to be used there. | -| **A2** | Single choke point identified; test that an unbound request is refused. | -| **A3** | Live decision against `flex-auth` with a denial observed at the endpoint, not only at the decision surface. | -| **E1** | Every tenant-owned table carries the tenant key. Schema-level check, mechanical. | -| **E2** | Choke point identified; adversarial test that an identity bound to tenant A cannot read tenant B. | -| **E3** | RLS policies present on every tenant-owned table; probe that a session without the GUC set reads nothing; probe that a wrong GUC reads nothing. | -| **E4** | Per-tenant credential demonstrated unable to connect to another tenant's substrate. | -| **P1–P4** | Provisioning declaration plus the platform's own isolation probes. | - **Decision 13.1:** a level is claimed only with its evidence artifact present. This turns §6's accuracy rule from an honour system into a check. -**Decision 13.2:** the E2 and E3 evidence artifacts do not exist anywhere in -the estate today. `rapp-postgres` runs 15 adversarial probes, all against the -*consumer* boundary, none against the tenant boundary inside a consumer. This -is the framework's largest live gap and §18.3 seeks an owner. +**Decision 13.2 — evidence is of two kinds, and conflating them is an +overclaim.** *Mechanical* evidence is a structural assertion a machine can make +and belongs in CI. *Adversarial* evidence is semantic, requires setting up +separate tenant contexts and comparing responses, and carries a review date +rather than a green build. Cross-tenant findings are the category external +testing practice identifies as needing human review. **A passing CI run is not +E2 evidence.** -## 14. Alternatives considered +| Level | Evidence | Kind | +|---|---|---| +| **I2** | Identifiers validated against the vocabulary; rejection test for a malformed id; binding shown to come from a verified token | Mechanical | +| **I3** | Live re-query demonstrated on an `aal2`-class path; cached-claim path shown unused there | Mechanical | +| **A2** | Choke point identified; test that an unbound request is refused | Mechanical | +| **A3** | Live decision with a denial observed at the endpoint, not only at the decision surface | Mechanical | +| **A4** | Decision served over the standard interface; a second PDP substituted without PEP change | Mechanical | +| **E1** | Every tenant-owned table carries the tenant key | Mechanical | +| **E2** | Choke point identified; identity bound to tenant A demonstrably cannot read tenant B | **Adversarial**, with a review date | +| **E3** | `FORCE ROW LEVEL SECURITY` on every tenant table; no `BYPASSRLS` on leased roles; probe that a session without the GUC reads nothing; probe that a wrong GUC reads nothing; `EXPLAIN` comparison | Mechanical | +| **E4** | Per-tenant credential demonstrated unable to connect to another tenant's substrate | Mechanical | +| **P1–P4** | Provisioning declaration plus the platform's isolation probes | Mechanical | +| **P1–P2 (noisy neighbour)** | One consumer saturating its connection or CPU allowance demonstrably does not breach another's | **Adversarial**, load-generated | +| **R2** | Declared retention rendered; erasure horizon published and reported in the operator surface | Mechanical | +| **R3** | Sweep evidence records: timestamp, dataset, identifiers removed, authorising policy reference | Mechanical | +| **R4** | Erasure demonstrated across live data, backups and derived copies within the horizon | **Adversarial** | -**One fixed model with a single set of characteristics** (draft-1). *Rejected -by the operator:* it cannot describe a repo that is not there yet, forcing -absorbed repos either to misrepresent their posture or to stay outside the -framework. A framework that can only describe its own end state is not a -framework. +**Decision 13.3:** the E2, E3 and noisy-neighbour artifacts do not exist +anywhere in the estate today. `rapp-postgres` runs 15 adversarial probes, all +against the *consumer* boundary, none against the tenant boundary inside a +consumer. Externally, what this framework calls a tenant boundary failure is +**Broken Object Level Authorization** — OWASP API1, top of the API Security Top +10 since that list launched, and the most commonly exploited API vulnerability +in published assessments. We have no coverage for the highest-ranked risk in +our class of system. §19.3 seeks an owner. -**A maturity model with a single overall level** (bronze/silver/gold). -*Rejected:* collapses the plane separation that makes the framework useful. A -service strong on identity and weak on enforcement has a specific, actionable -gap; one composite score hides it and invites averaging. +## 14. Adoption stance — structure, not tooling -**Schema-per-consumer in one database** (sanctioning P0). *Rejected:* -`pg_catalog` is readable per-database, so every co-resident enumerates every -other's table and column names regardless of `GRANT`s. Retained in the ladder -as a describable state, never as a sanctioned target. +**Decision 14.1:** external research is design input. This estate adopts +published standards and structural patterns; it does not adopt tooling unless +that tooling is an established industry standard with broad application. +Everything else is built ground-up, so it can be optimised and refactored as +the estate sees fit. -**Row-level security as the *consumer* boundary.** *Rejected:* puts a -correctness-critical boundary between two services inside query predicates. -Retained as E3 for the *tenant* boundary, where the realistic alternative is -application code rather than a database. +| Class | Stance | +|---|---| +| Security baselines (OWASP Multi-Tenant Security Cheat Sheet, API Security Top 10) | Adopt as the external reference our ladders answer to | +| Standards bodies (OpenID AuthZEN 1.0) | Adopt — this is what A4 is | +| Reference taxonomies (Azure tenancy models, AWS SaaS Lens, cell architecture) | Adopt as structure | +| Engine behaviour (PostgreSQL RLS mechanics) | Facts, not tooling | +| Third-party analyzers and test frameworks | **Do not adopt.** Take their rule taxonomies as checklists for probes we write ourselves | -**Mandating E4 for everyone.** *Rejected:* the tenant grouping taxonomy -includes `consumer` (private individuals) and `family`. A cluster per private -individual is economically impossible; the taxonomy is itself evidence that -pooling is required. +The practical effect is small and good: `rapp-postgres` already owns a +ground-up probe harness — bash and psql, no dependency tree — that found four +real defects in its own provisioning SQL. The evidence artifacts in §13 become +new probes in a tool we control. One idea worth reimplementing from the +external survey is **policy-diff classification**: labelling a change to an +enforcement policy as safe or breaking *before* it lands. -**Leaving each repo to its own model.** *Rejected:* this is the status quo, -and it produced two contradictory ratified defaults plus an unowned placement -question. +## 15. Alternatives considered -## 15. Challenge against outside practice +**One fixed model with a single set of characteristics** (draft-1). *Rejected:* +cannot describe a repo that is not there yet, forcing absorbed repos to +misrepresent their posture or stay outside. A framework that can only describe +its own end state is not a framework. -**Where the framework is stronger than typical.** Most SaaS multi-tenancy -literature (AWS's silo/pool/bridge framing among them) models exactly one -boundary — tenant-to-tenant. This estate has **two stacked boundaries**: -platform-service-to-platform-service, and tenant-to-tenant inside a consumer. -Naming them separately and refusing to enforce both with one mechanism is -uncommon and correct. +**A maturity model with a single overall level.** *Rejected:* collapses the +plane separation. A service strong on identity and weak on enforcement has a +specific, actionable gap; one composite score hides it and invites averaging. -Graduated per-plane levels are also stronger than the usual silo/pool/bridge -trichotomy, which describes deployment shape only. Silo/pool/bridge is -approximately this framework's P plane with the other three missing — which is -why it cannot express "pooled infrastructure, structurally enforced -boundary", or the reverse. +**Prohibiting row-level security** (draft-2's inherited position). *Rejected in +draft-2, refined in draft-3:* RLS is a real rung against the common threat. The +error was never RLS — it was describing E3 in E4's language. -**Where it is weaker, and this is the finding that matters.** The pool model's -standard mitigation is a *verified* enforcement layer that every service is -demonstrably routed through. The estate has the concept (E2/E3) and none of -the verification (§13.2). A single missing `WHERE tenant_id = ?` in any -consumer is a cross-tenant breach that no platform control catches, no probe -fails, and no log shows as an error. +**Schema-per-consumer in one database.** *Rejected:* `pg_catalog` is readable +per-database, so every co-resident enumerates every other's table and column +names regardless of grants. Retained as a describable state, never a target. -**Where the estate has quietly adopted a good pattern without naming it.** -Short-lived leased credentials re-read at checkout, with overlap-first -rotation, beat the industry norm of a long-lived per-service secret. §9 -promotes it to a tenancy control. +**Mandating E4 for everyone.** *Rejected:* the tenant taxonomy includes +`consumer` (private individuals) and `family`. A cluster per private individual +is economically impossible; the taxonomy is itself evidence pooling is +required. -**Cell-based architecture remains unexplored.** Neither P nor E describes a -cell — a fixed-size slice of infrastructure serving a bounded set of tenants, -sized so one cell's failure is survivable and cell count scales linearly. -Given the estate spans railiance, NetKingdom, HelixForge and Coulomb, cells -may fit better than per-tenant clusters at the point P1 runs out (§18.8). +**Per-consumer physical backup retention.** *Rejected:* CNPG retention is a +property of the instance's WAL archive. There is no mechanism, and claiming it +would be a fabricated guarantee. Hence the derived maximum in §4.5. -## 16. Scaling demands +**Platform-scheduled row expiry.** *Rejected:* requires the platform to hold +DML authority over consumer schemas and interpret consumer data semantics, both +forbidden by ADR-0001. The consumer's migration lease is the correct +instrument. + +**Leaving each repo to its own model.** *Rejected:* the status quo, which +produced two contradictory ratified defaults and an unowned placement question. + +## 16. Held against outside practice + +**The graduated reframe is corroborated, not invented here.** Microsoft's +tenancy-model guidance states it almost verbatim: *"Instead of viewing +isolation as a discrete property, consider it a spectrum. You can deploy +components of your architecture that are more isolated or less isolated than +other components in the same architecture."* The same guidance derives our E↔P +coupling independently — shared deployment means enforcement lives in +application code; dedicated deployment means it is structural. + +**Stronger than typical.** Most multi-tenancy literature models one boundary, +tenant-to-tenant. This estate has **two stacked boundaries**: platform-service +to platform-service, and tenant to tenant inside a consumer. Naming them +separately and refusing to enforce both with one mechanism is uncommon and +correct. Graduated per-plane levels also beat the silo/pool/bridge trichotomy, +which is approximately our P plane with the other four missing — which is why +it cannot express "pooled infrastructure, structurally enforced boundary". + +**Weaker than typical.** The pool model's standard mitigation is a *verified* +enforcement layer every service is demonstrably routed through. We have the +concept and none of the verification (§13.3). + +**Adopted without naming it.** Short-lived leased credentials re-read at +checkout beat the long-lived-secret norm. §9 promotes it to a tenancy control. + +**Still unexplored.** Neither P nor R describes a **cell** — a slice of +infrastructure with a *fixed maximum size*, sized so one cell's failure is +survivable and cell count scales linearly. `platform-pg` is, in these terms, an +uncapped cell: §17 computes a ceiling and nothing enforces it (§19.8). + +Sources: the four research digests in `research/2026-08-17-adr008-*`, which +carry full citations for every claim in this section. + +## 17. Scaling demands Measured against the live `platform-pg` specification, not estimated. @@ -474,81 +561,98 @@ memory limit: 1Gi per consumer: 14 connections (12 runtime + 2 migration) ``` -**Connection ceiling: roughly six consumers.** Seven consumers request 98 of -100 before CNPG's instance manager, metrics exporter, and reserved slots. Two -are provisioned today. **We are at roughly a third of capacity and the third -request will not feel like a scaling event.** +**Connection ceiling: roughly six consumers.** Seven request 98 of 100 before +CNPG's instance manager, metrics exporter and reserved slots. Two are +provisioned. We are at roughly a third of capacity and the third request will +not feel like a scaling event. -**Memory likely binds first.** 100 backends against 1Gi is ~10MB per backend -for shared buffers, work_mem and overhead. Connection exhaustion errors -clearly; memory pressure OOM-kills and degrades every co-resident at once. +**Memory likely binds first.** 100 backends against 1Gi is ~10MB per backend. +Connection exhaustion errors clearly; memory pressure OOM-kills and degrades +every co-resident at once. -**Raising E is not free on a shared substrate.** RLS with a per-checkout GUC -does not itself increase connection count, but it forecloses aggressive -transaction-level pooling for consumers that adopt it. E3 on P1 interacts with -the ceiling above and should be planned against it. +**E3 and pooling.** *Corrected from draft-2, which had this backwards.* +Transaction-scoped context (`SET LOCAL` inside an explicit transaction) is what +makes E3 **safe** under a pooler. Statement-level pooling is what breaks it, +serving other tenants' rows under concurrency with no error. E3 constrains +which pooling mode is available, not whether pooling is available. + +**Retention consumes the volume.** WAL accumulates with the window, and §4.5 +makes the window the maximum across consumers. A consumer declaring a long +retention extends everyone's horizon *and* everyone's storage draw against a +20Gi volume. **Restore time couples all consumers.** Physical backup is instance-wide, so a -consumer's RTO is a function of *total* instance size, not its own. One large -consumer silently degrades every co-resident's recovery promise. +consumer's RTO is a function of *total* instance size, not its own. -**`instances: 1` means no P1 tenant has HA.** A tier promising uptime cannot -be satisfied at P1 as currently built — an availability floor belongs in §11's +**No P1 tenant has HA.** `instances: 1` means a tier promising uptime cannot be +satisfied at P1 as built — an availability floor belongs in §11's minimum-level vocabulary alongside isolation. -## 17. Consequences +## 18. Consequences -- The estate gains one vocabulary and, more importantly, a way to be honest - about partial adoption. +- The estate gains one vocabulary and a way to be honest about partial + adoption. - Absorbed repos get a described state and a path instead of a failing grade. -- `tenantIsolation` in `PostgresConsumer` is revealed as a mislabelled field - (§18.1). -- The two ratified defaults are reconciled by scope, at the cost of needing a - classification rule. -- The verification problem becomes tractable: guard against declaration, not - against a universal maximum. -- Draft-1's prohibition on RLS is reversed; `rapp-postgres` acquires an - obligation to define and offer the E3 mechanism. +- `tenantIsolation` in `PostgresConsumer` is revealed as a mislabelled field. +- The verification problem becomes tractable: guard against declaration. +- Draft-2's RLS prohibition is reversed and its E3 description corrected; + `rapp-postgres` acquires an obligation to define and offer the mechanism. +- Adding a consumer with long retention **silently extends everyone's erasure + horizon**. This must reach the consumer review checklist, not only this + document. +- A service selling an isolation tier must maintain a tenant→substrate mapping + it does not have today. - Nothing here changes a running system. -## 18. Open questions +## 19. Open questions 1. **`tenantIsolation` field** — `rapp-postgres`: rename to name its plane and carry a level (`tenancy.E: 2`), or move it out of the storage declaration. -2. **Placement ownership** — `railiance-platform`: accept the ladder, the - triggers, and the monitoring obligation in §8.1, and appoint a recorded - placement owner per workload. -3. **E2/E3 evidence artifacts** — *owner needed.* Who builds the adversarial - cross-tenant probes and the RLS conformance check? Both `audit-core` and - `tenant-engine` have declined fleet-scope work on correct boundary - reasoning, so this needs appointing. Highest-severity gap (§13.2). +2. **Placement ownership** — `railiance-platform` with `adaptive-pricing`: + accept the ladder, triggers and the §8.1 monitoring obligation; appoint a + recorded placement owner per workload. +3. **E2, E3 and noisy-neighbour evidence** — *owner needed.* Now three + artifacts of two kinds: E3 and noisy-neighbour are buildable as probes in + the existing harness; E2 is adversarial and needs a review cadence. Both + `audit-core` and `tenant-engine` have declined fleet-scope work on correct + boundary reasoning, so this needs appointing. Highest-severity gap. 4. **Business app vs platform service** — Custodian canon: a classification - rule, so a new repo knows whether P3 or P1 is its default. Candidate: reuse - `repo-classification-standard_v1.0` rather than invent a second taxonomy. + rule. Candidate: reuse `repo-classification-standard_v1.0`. 5. **Tier → minimum level mapping** — `adaptive-pricing` and `tenant-engine`: - required only for tiers making isolation or availability claims (§11). -6. **E3 mechanism** — `rapp-postgres`: what exactly is the GUC contract, and - is it provisioned per consumer on request or offered as a standard option? -7. **Engine neutrality** — the P ladder rests on a PostgreSQL property - (per-database catalogs). State it engine-specifically and say so, or - abstract it and risk a non-Postgres implementation that silently differs? - Inherited unresolved from the v0.1 draft. -8. **Cell-based partitioning** — evaluate at consumer #4 rather than now? - Neither P nor E describes a cell. + required only for tiers making isolation, availability or retention claims. +6. **The E3 mechanism** — `rapp-postgres`: publish the GUC contract with the + `FORCE`/`BYPASSRLS`/`SECURITY INVOKER`/`EXPLAIN` requirements in §4.3. +7. **Identity-provider placement** — owner of `key-cape`: realm-per-tenant or + Organizations? Realm-per-tenant's ~5–20 tenant ceiling is below our target. +8. **Cell sizing** — reframed from "should we adopt cells" to **"what is + `platform-pg`'s declared maximum size, and what is the overflow target?"** + The connection ceiling forces this whether or not we adopt the vocabulary. +9. **Retention floor and ceiling** — should `backupRetentionDays` have a + platform minimum (so a consumer asking for 1 day gets a validation error + rather than a quiet disappointment) and a maximum (so nobody exhausts the + volume)? +10. **Engine neutrality** — the P ladder rests on a PostgreSQL property. + State it engine-specifically and say so, or abstract it and risk a + non-Postgres implementation that silently differs? -## 19. Ratification path +**Routed elsewhere, deliberately.** The tenant identifier +`tenant::` embeds headcount bands (`small`, `medium`, `large`) +that change as a tenant grows, contradicting the consensus that identifiers +should not encode mutable attributes. That is a critique of ADR-0013, not of +this framework, and belongs to `tenant-engine` and NetKingdom canon. Folding it +in here would overreach. -The predecessor draft stalled because it was routed without one. +## 20. Ratification path 1. Reviewed by `tenant-engine`, `flex-auth`, `rapp-postgres`, - `railiance-platform`, and `adaptive-pricing` against §18. -2. Each of those repos publishes its own posture vector (§5) as part of - review. **The framework is validated by whether it can describe them - accurately** — if a repo cannot express itself in these four ladders, the - ladders are wrong and this document changes, not the repo. + `railiance-platform` and `adaptive-pricing` against §19. +2. Each publishes its own posture vector (§5) as part of review. **The + framework is validated by whether it can describe them accurately** — if a + repo cannot express itself in these five ladders, the ladders are wrong and + this document changes, not the repo. 3. On acceptance, **supersedes** the routing of `rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1-draft.md`, - whose §§3–8 are absorbed here. That draft is then withdrawn rather than - left pending. -4. On acceptance, `rapp-postgres/docs/adr/ADR-0001` moves to `accepted` and is - annotated as the PostgreSQL implementation of the E and P ladders. + whose §§3–8 are absorbed here. That draft is withdrawn rather than left + pending. +4. On acceptance, `rapp-postgres` ADR-0001 and ADR-0002 move to `accepted` and + are annotated as the PostgreSQL implementation of the E, P and R ladders. From e03c280f40cda425a7c34ce069263b4c73e5039c Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 17 Aug 2026 14:37:02 +0200 Subject: [PATCH 4/7] =?UTF-8?q?research:=20close=20the=20two=20gaps=20?= =?UTF-8?q?=E2=80=94=20erasure=20mechanism=20and=20noisy-neighbour=20evide?= =?UTF-8?q?nce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings change a level definition in draft-3, so neither is decoration. R4 had no mechanism. As drafted it meant erasure completes by waiting out a horizon somebody else's retention set, which is honest and useless as proof. Crypto-shredding is the established answer: encrypt per entity, destroy the key, leave the immutable backup unreadable. DPAs have accepted it where physical deletion is disproportionate; the EDPB has not formally endorsed it, and R4 should say that rather than imply a clean yes. The finding underneath it is that shredding granularity is bounded by the key boundary. Per-consumer shred of logical exports is close - the age lane and OpenBao already exist. Per-consumer shred of physical backups is not available, for the same reason retention is not per-consumer. And per-TENANT shred needs the application to encrypt under a tenant key before writing, which makes the top of the retention ladder an enforcement-plane capability. That is a third coupling the ADR does not record. Also flagged: crypto-shredding an audit trail destroys the evidence audit-core exists to hold. A naive R4-everywhere target would instruct it to do exactly that. A question for audit-core, not an answer this framework should give. The noisy-neighbour artifact asserted something unachievable - that one consumer saturating its allowance does not breach another's. Azure's first line of solution text is that the risk cannot be completely avoided. An artifact that can only fail, or that passes by being run gently, is an overclaim dressed as evidence. Replaced with characterisation: a recorded baseline, a saturation run, proof the controls bind, and the measured degradation written down. That research also reframes the connection ceiling. Seven consumers each politely inside a 14-connection allowance still exhaust the instance, which is the aggregate noisy-neighbour shape - so the number in the scaling section is not a capacity statistic, it is the bound. Co-Authored-By: Claude Opus 5 --- ...ap-research-erasure-and-noisy-neighbour.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 research/2026-08-17-adr008-gap-research-erasure-and-noisy-neighbour.md diff --git a/research/2026-08-17-adr008-gap-research-erasure-and-noisy-neighbour.md b/research/2026-08-17-adr008-gap-research-erasure-and-noisy-neighbour.md new file mode 100644 index 0000000..8a95cf0 --- /dev/null +++ b/research/2026-08-17-adr008-gap-research-erasure-and-noisy-neighbour.md @@ -0,0 +1,265 @@ +# Gap research — erasure mechanism (R4) and noisy-neighbour evidence + +> Research artefact — 2026-08-17. Fifth digest supporting +> `canon/architecture/adr-008-multi-tenancy-model.md` (draft-3). Closes the two +> gaps the four plane digests left open: how `R4` (verified erasure) can be +> reached at all, and what a noisy-neighbour evidence artifact should actually +> assert. +> **Both findings change a level definition in draft-3.** +> Index: `2026-08-17-adr008-multi-tenancy-research-index.md`. + +--- + +# Part 1 — Erasure: R4 needs a mechanism it does not currently have + +## 1.1 The problem draft-3 left standing + +ADR-008 §4.5 defines `R4` as "deletion proven complete across live data, +backups and derived copies within the published horizon", and `rapp-postgres` +ADR-0002 establishes that on shared substrate the horizon is the instance +maximum — every co-resident consumer's retention window, whichever is longest. + +Read together, those say: **erasure completes by waiting.** A tenant exercising +a deletion right is told the data is gone in up to N days, where N is set by +somebody else's retention requirement. That is honest, and it is a weak answer. +It also makes `R4` unreachable in any useful sense, because "we waited" is not +a proof. + +## 1.2 Crypto-shredding is the established mechanism + +The industry answer to erasure under immutable or long-retention storage is to +make the data unreadable rather than absent. + +> "Crypto-shredding for backups encrypts each subject (or tenant) with a unique +> key derived from the subject identifier, and on erasure, destroys the key. +> The backups remain but are cryptographically inaccessible." +> — [oneuptime — setting up crypto-shredding for GDPR right-to-erasure](https://oneuptime.com/blog/post/2026-02-17-how-to-set-up-crypto-shredding-for-gdpr-right-to-erasure-compliance-in-google-cloud/view) + +It is aimed precisely at our situation: the named use cases are "append-only or +immutable storage (event sourcing, WORM, immutable backups) where physical +deletion is impossible" +([Granit — crypto-shredding: GDPR erasure without deleting rows](https://granit-fx.dev/blog/crypto-shredding-gdpr-erasure-without-deleting-rows/)). + +**Regulatory standing, stated carefully because overclaiming here would be +worse than in any other section of this framework.** The EDPB has *not* formally +endorsed crypto-shredding as Article 17 erasure. Several data protection +authorities have accepted it where physical deletion would be manifestly +disproportionate effort under Article 17(1), and EDPB Guidelines 5/2019, the UK +ICO and the French CNIL are cited as recognising it **provided three conditions +hold**: strong encryption (AES-256), irreversible key destruction, and auditable +destruction +([pg_hardstorage — GDPR Art. 17 crypto-shred](https://github.com/cybertec-postgresql/pg_hardstorage/blob/main/docs/compliance/gdpr-art-17-crypto-shred.md), +[Opsio — right to erasure in cloud systems](https://opsiocloud.com/blogs/right-to-erasure-article-17-gdpr-deletion-cloud-systems/)). + +The third condition is the one this estate is well placed for and the one most +implementations skip: **destruction must be auditable**. ADR-0002 already +requires every sweep to emit an evidence record. Key destruction is the same +shape. + +## 1.3 The finding: shredding granularity is bounded by the key boundary + +Crypto-shredding "requires per-entity key isolation: each entity instance gets +its own encryption key" (oneuptime, ibid.). That single requirement determines +what is available to us, and it is not uniform across the estate. + +| What we want to shred | Key boundary needed | Available today? | +|---|---|---| +| A consumer's logical exports | Per-consumer key | **Close.** The offsite lane already encrypts with `age`, and OpenBao holds key material. This is a key-management change, not an architecture change. | +| A consumer's physical backup | Per-consumer key on the Barman object store | **No.** One WAL stream, one credential, one encryption context for the whole instance. Same constraint that forced the derived maximum in ADR-0002 §2. | +| A single tenant's rows inside a consumer | Per-tenant key, applied by the application before the data reaches storage | **No, and not the platform's to build.** Requires application-level per-tenant encryption. | + +The third row is the architecturally interesting one. Per-tenant crypto-shred +means the consumer encrypts tenant data under a tenant-specific key before +writing it — which is an **enforcement-plane** property, not a retention one. It +also happens to be a strong one: data encrypted per tenant is unreadable across +the boundary even by a credential that can address the rows, which is E4-class +protection achieved without per-tenant substrate. + +**This is a new coupling and it belongs in §3.2.** Draft-3 records two +couplings (E4 needs P3; R's horizon is bounded by P). There is a third: +**R4 at tenant granularity requires per-tenant keys, which is an E-plane +capability.** Reaching the top of the retention ladder is not a retention +project. + +A reference implementation exists for the consumer-granularity case: +`pg_hardstorage` "implements crypto-shred by destroying the per-tenant KEK that +wraps every encrypted backup's DEK" (ibid.). Per the §14 adoption stance we +take the **structure** — envelope encryption with a per-consumer key-encryption +key wrapping each backup's data-encryption key, and destruction of the KEK as +the erasure act — and build it on our own OpenBao and `age` lane rather than +adopting the extension. + +## 1.4 The tension nobody should paper over: erasure versus audit + +`audit-core` exists to hold a tamper-evident trail. Crypto-shredding a tenant's +audit records destroys evidence, and ADR-0001 §2 deliberately built the role +model so that "a compromised runtime credential could not rewrite history at +the schema level". Erasure is a sanctioned way to do exactly that. + +The literature treats this as a genuine design tension rather than a solved +problem ([Remote Reason — balancing auditability and privacy with crypto-shredding](https://remotereason.com/blog/balancing-auditability-and-privacy-with-crypto-shredding)). +The usual resolution is to separate the *fact* of an event (retained, not +personal) from its *personal payload* (encrypted per subject, shreddable), so +the trail survives erasure with its personal content removed. + +**This is a question for `audit-core`, not an answer this framework should +supply.** Flagging it because a naive "R4 everywhere" target would silently +instruct the audit service to destroy its own evidence. + +## 1.5 Proposed amendments to draft-3 + +1. **`R4` admits two routes, and must name which one a service uses:** + - *Horizon-elapsed* — deletion is complete once the published erasure + horizon has passed. Available to everyone, proves little, slow. + - *Key-destroyed* — data is rendered unreadable immediately by destroying the + key that decrypts it. Requires per-entity keys, AES-256-class encryption, + and an auditable destruction record. +2. **Add the third coupling to §3.2:** R4 at tenant granularity requires an + E-plane capability (per-tenant encryption). R4 at consumer granularity + requires per-consumer keys on the retained artifacts. +3. **State the regulatory position honestly** wherever R4 is described: DPAs + have accepted key destruction as erasure under conditions; the EDPB has not + formally endorsed it. A plan tier claiming "your data is deleted" on the + strength of crypto-shredding is making a defensible but not settled claim, + and §11.4's hard line should say so rather than implying R4 is a clean yes. +4. **Raise the audit tension** as an open question addressed to `audit-core`. + +--- + +# Part 2 — Noisy neighbour: the evidence artifact asserts the wrong thing + +## 2.1 The problem draft-3 left standing + +ADR-008 §13 proposes a P1/P2 evidence artifact: "one consumer saturating its +connection or CPU allowance demonstrably **does not breach** another's." + +That is not achievable, and the reference guidance says so in its first line of +solution text: + +> "Sharing a single resource inherently carries the risk of noisy neighbor +> problems that you can't completely avoid." +> — [Azure Architecture Center — Noisy Neighbor antipattern](https://learn.microsoft.com/en-us/azure/architecture/antipatterns/noisy-neighbor/noisy-neighbor) + +An artifact that can only ever fail, or that passes by being run gently enough, +is worse than no artifact — it is the overclaim §6 prohibits, dressed as +evidence. + +## 2.2 There are two failure shapes, and we only guard against one + +Azure distinguishes them explicitly: + +- **One greedy tenant.** "At peak times, tenant A uses all of the system's + resources, which means that any requests that tenant B makes fail." (ibid.) +- **Aggregate of many modest tenants.** "The noisy neighbor problem also occurs + when each individual tenant consumes only a small portion of the system's + capacity. However, the combined resource usage of many tenants can result in + a peak in overall usage." (ibid.) + +Our per-consumer governance — `connection_limit`, `statement_timeout`, +`idle_in_transaction_session_timeout`, applied at database scope per ADR-0001 +§3 — addresses the first shape well. It does nothing about the second. + +**And the second shape is exactly what §17 computes.** Seven consumers, each +politely within its 14-connection allowance, request 98 of 100 connections. +Every one of them is well-behaved; the instance still fails. The connection +ceiling in §17 is not merely a capacity number — **it is the aggregate +noisy-neighbour bound**, and framing it as a scaling statistic understates what +it is. + +## 2.3 The detection signature we can actually build + +Azure's service-side diagnostic is specific and unintuitive: + +> "**Failures when performing an operation for a tenant:** Look for failures +> that occur when a tenant isn't consuming a large share of the system's +> resources. This pattern might indicate that the tenant is experiencing a +> noisy neighbor problem. Track resource consumption by tenant." (ibid.) + +The signal is a failure for a *quiet* consumer. That requires per-consumer +resource attribution, and we already have the raw material: ADR-0001 stamps +`application_name` per role (`/runtime`), PostgreSQL logs carry +database, user, application name and client address, and +`make postgres-ops-status` already reports sessions by application name. + +What is missing is a **baseline**. "It's important to clearly understand your +normal baseline resource usage and to configure monitoring and alerts to detect +spikes" (ibid.). No baseline for `platform-pg` has been recorded. Without one, +a spike is indistinguishable from a Tuesday. + +## 2.4 A normative requirement we are not meeting + +> "Be transparent with clients about any throttling mechanisms or usage quotas +> that you enforce. It's important that they handle failed requests gracefully +> and aren't caught off guard by limitations." (ibid.) + +`tenant-engine` was told its connection and timeout limits during provisioning, +which is good practice by accident rather than by rule. Nothing requires it. A +consumer discovering its `statement_timeout` by hitting it in production is the +failure this warns about. + +**Proposed:** fold quota transparency into §10 (blast radius must be published) +— the same disclosure obligation, extended from what a leaked credential can +reach to what the platform will refuse to do for you. + +## 2.5 Mitigations we have, and the ones we do not + +| Azure-recommended control | Our status | +|---|---| +| Query time limits / max returnable records | **Have.** `statement_timeout`, per-role since the tenant-engine change. | +| Connection quotas per tenant | **Have.** Database-scoped, per ADR-0001 §3. | +| Monitor overall *and* per-tenant resource usage | **Partial.** Attribution exists; no baseline, no alerting. | +| Throttling / rate limiting | **None.** No request-rate control at the database boundary. | +| Quality-of-service prioritisation | **None.** All consumers are equal, including on the synchronous authorization path where `tenant-engine` now sits. | +| Run heavy non-time-sensitive work off-peak | **Implicit.** Backups at 02:15; nothing else scheduled. | +| Rebalance tenants across stamps | **Not possible.** One instance, no second cell (§19.8). | + +The QoS absence is worth noting against a specific fact: `tenant-engine` chose +a 5s statement timeout because `flex-auth` calls it synchronously on the +authorization path. That is a latency-critical consumer sharing an instance +with `audit-core`, which is not. Nothing prioritises one over the other, and +the framework has no vocabulary for saying one consumer's latency matters more. + +## 2.6 Proposed amendment to draft-3 + +Replace the §13 P1/P2 artifact with one that asserts something true: + +> **Noisy-neighbour characterisation (P1/P2, adversarial, load-generated).** +> A recorded baseline of normal per-consumer resource usage; a load run in +> which one consumer saturates its declared allowance; evidence that (a) the +> governance controls bind — the greedy consumer is throttled at its declared +> limits rather than exceeding them, and (b) the measured degradation +> experienced by co-resident consumers is recorded and judged acceptable. +> Carries a review date and the aggregate headroom at time of measurement. + +That is achievable, it is falsifiable, and it produces a number — degradation +under contention — that a plan tier promising performance would need anyway. + +--- + +## 3. Summary of changes these two gaps force + +| # | Change | Where | +|---|---|---| +| A | `R4` splits into *horizon-elapsed* and *key-destroyed* routes; a service must name which | §4.5 | +| B | Third coupling: tenant-granularity R4 requires an E-plane capability (per-tenant encryption) | §3.2 | +| C | R4's regulatory standing stated honestly — accepted by DPAs under conditions, not formally endorsed by the EDPB | §4.5, §11.4 | +| D | Erasure-versus-audit tension raised as an open question to `audit-core` | §19 | +| E | Noisy-neighbour artifact reworded from "does not breach" to characterisation with a recorded baseline and measured degradation | §13 | +| F | §17's connection ceiling reframed as the aggregate noisy-neighbour bound, not only a capacity figure | §17 | +| G | Quota transparency added as a disclosure obligation | §10 | +| H | No QoS vocabulary despite a latency-critical consumer sharing with a batch one | §19, new | + +## Sources + +- [oneuptime — How to set up crypto-shredding for GDPR right-to-erasure compliance](https://oneuptime.com/blog/post/2026-02-17-how-to-set-up-crypto-shredding-for-gdpr-right-to-erasure-compliance-in-google-cloud/view) +- [cybertec-postgresql/pg_hardstorage — GDPR Art. 17 crypto-shred](https://github.com/cybertec-postgresql/pg_hardstorage/blob/main/docs/compliance/gdpr-art-17-crypto-shred.md) +- [Granit — Crypto-shredding: GDPR erasure without deleting a single row](https://granit-fx.dev/blog/crypto-shredding-gdpr-erasure-without-deleting-rows/) +- [Granit — Crypto-shredding (dotnet/compliance)](https://granit-fx.dev/dotnet/compliance/crypto-shredding/) +- [Opsio — Right to erasure, Article 17 GDPR, deletion in cloud systems](https://opsiocloud.com/blogs/right-to-erasure-article-17-gdpr-deletion-cloud-systems/) +- [Remote Reason — Balancing auditability and privacy with crypto-shredding](https://remotereason.com/blog/balancing-auditability-and-privacy-with-crypto-shredding) +- [Azure Architecture Center — Noisy Neighbor antipattern](https://learn.microsoft.com/en-us/azure/architecture/antipatterns/noisy-neighbor/noisy-neighbor) +- [Neon — The noisy neighbor problem in multitenant architectures](https://neon.com/blog/noisy-neighbor-multitenant) +- [PingCAP — Playbook: stop noisy neighbors, multi-tenant MySQL at scale](https://www.pingcap.com/playbook-noisy-neighbor-multi-tenant-mysql/) +- [Causal inference for quantifying noisy neighbor effects in multi-tenant cloud environments (arXiv 2604.03145)](https://arxiv.org/pdf/2604.03145) +- [Azure Architecture Center — Throttling pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/throttling) +- [Azure Architecture Center — Rate Limiting pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/rate-limiting-pattern) From c4f22a5e55d9220ec417918643048ae6932623be Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 17 Aug 2026 14:49:00 +0200 Subject: [PATCH 5/7] ADR-008 draft-4: apply the eight amendments the gap research forced R4 now has a mechanism. It splits into horizon-elapsed (wait out a window somebody else's retention set - available to all, proves little) and key-destroyed (encrypt per entity, destroy the key, immediate). A service must name which route it uses. The regulatory position is stated rather than implied: DPAs have accepted key destruction as erasure where physical deletion is disproportionate, under conditions, and the EDPB has not formally endorsed it. Section 11.4 now says a service may make that claim but not in language implying a regulator blessed it. Overclaiming here would be worse than anywhere else in the document. Third coupling recorded: shredding a single tenant's data needs the application to encrypt under a per-tenant key before writing, so the top of the retention ladder is an enforcement-plane capability. Reaching R4 is not a retention project. The noisy-neighbour artifact was replaced. It had required proof that a saturating consumer does not breach another's allowance, which shared infrastructure cannot provide - an artifact that can only fail, or passes by being run gently, is an overclaim in the costume of evidence. It now measures: baseline, saturation run, proof the controls bind, recorded degradation. Generalised as decision 13.4. The connection ceiling is reframed. Seven consumers each politely inside a 14-connection allowance still exhaust the instance, so the number is the aggregate noisy-neighbour bound, and our per-consumer governance guards the other shape entirely. Also: quota transparency as a disclosure obligation (10.2) - a consumer learning its statement timeout by hitting it in production is our failure, not theirs; and two new open questions - crypto-shredding an audit trail destroys what audit-core exists to hold, and we have no QoS vocabulary despite a latency-critical consumer sharing an instance with a batch one. Co-Authored-By: Claude Opus 5 --- .../adr-008-multi-tenancy-model.md | 95 ++++++++++++++++--- 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/canon/architecture/adr-008-multi-tenancy-model.md b/canon/architecture/adr-008-multi-tenancy-model.md index 15bdc3f..6e4273a 100644 --- a/canon/architecture/adr-008-multi-tenancy-model.md +++ b/canon/architecture/adr-008-multi-tenancy-model.md @@ -5,7 +5,7 @@ title: "Tenancy Posture: Five Planes, Graduated Levels, Declared Conformance" status: proposed decided_by: Bernd Worsch date: "2026-08-17" -revision: "draft-3" +revision: "draft-4" tags: ["architecture", "multi-tenancy", "isolation", "placement", "retention", "maturity", "tenant-engine", "flex-auth", "rapp-postgres", "scaling"] --- @@ -13,16 +13,22 @@ tags: ["architecture", "multi-tenancy", "isolation", "placement", "retention", " ## Status -**Proposed, draft-3.** +**Proposed, draft-4.** - **draft-1** proposed a single model with fixed characteristics. Rejected: it could not describe a repo that is not there yet. - **draft-2** reframed to graduated levels per plane. Externally corroborated (§16), but four of its statements were wrong and one thing it needed was missing. -- **draft-3** applies those corrections, adds the retention plane, and records - an adoption stance. It is informed by four external research digests, one per - original plane, in `research/2026-08-17-adr008-*`. +- **draft-3** applied those corrections, added the retention plane, and + recorded an adoption stance. +- **draft-4** closes the two gaps draft-3 left open: `R4` had no mechanism + beyond waiting, and the noisy-neighbour evidence artifact asserted something + shared infrastructure cannot provide. Both were found by research, not by + review. + +Informed by five external research digests in `research/2026-08-17-adr008-*`, +which carry full citations for every external claim made here. Reviewed by nobody yet. §19 lists what each owner is being asked to accept. @@ -100,6 +106,11 @@ stated where they apply, not used to argue the planes are one: - `E4` is reachable only at `P3` or above. - `R`'s erasure horizon is bounded below by `P` — on shared substrate, a consumer's horizon is the instance maximum (§4.5). +- `R4` by key destruction is bounded by the **key boundary**, which is an + E-plane property. Shredding a single tenant's data requires the application + to encrypt under a per-tenant key before writing; the storage platform cannot + supply it. **Reaching the top of the retention ladder is not a retention + project.** **Decision 3.3 — scope.** The P and R ladders describe a service's **primary datastore**. Caches, search indices, message queues and background jobs are @@ -243,9 +254,25 @@ layer. Reference implementation: `rapp-postgres` ADR-0002. | **R1** | Platform default retention applies (N=30 days). The consumer has declared no requirement. | | **R2** | Retention declared as N days per dataset; the **erasure horizon** is published, and the consumer makes no promise shorter than it. | | **R3** | Policy-driven deletion: the consumer or its governance layer declares what is due, the platform sweeps whole datasets on that instruction and evidences each run. | -| **R4** | Verified erasure: deletion proven complete across live data, backups and derived copies within the published horizon. | +| **R4** | Verified erasure: data proven unrecoverable across live storage, backups and derived copies, by one of the two routes below. | -Three properties. +**R4 has two routes and a service MUST name which one it uses.** + +| Route | Mechanism | Cost | +|---|---|---| +| **Horizon-elapsed** | Wait out the published erasure horizon; the data ages out of every retained copy. | Available to everyone, proves little, and the wait is set by a co-resident's retention requirement rather than your own. | +| **Key-destroyed** | Encrypt per entity, then destroy the key. Retained copies survive but are unreadable. | Requires per-entity keys, strong encryption, and an auditable destruction record. Immediate. | + +**Regulatory standing of the key-destroyed route, stated carefully because +overclaiming here is worse than anywhere else in this framework.** Data +protection authorities have accepted key destruction as erasure where physical +deletion would be manifestly disproportionate, and the practice is recognised +under conditions — strong encryption, irreversible destruction, and an auditable +record of it. **The EDPB has not formally endorsed it as Article 17 erasure.** A +service reaching R4 by key destruction is making a defensible claim, not a +settled one, and must say so rather than reporting a clean "deleted". + +Three further properties. **The erasure horizon is the interval between deleting data and it ceasing to be recoverable from anything the platform holds.** Deleting a row does not @@ -380,6 +407,15 @@ levels it operates. `rapp-postgres` ADR-0001 §5 is the reference. Where the model cannot provide a guarantee, the platform says so and names the escalation. +**Decision 10.2 — quotas are disclosed, not discovered.** The same obligation +extends from what a leaked credential can reach to what the platform will +refuse to do for you. Every consumer MUST be told, at provisioning, the +throttles and quotas enforced against it — connection limits, statement +timeouts, idle-transaction timeouts — and told again when they change. A +consumer learning its statement timeout by hitting it in production is a +disclosure failure, not a consumer bug. This is how `tenant-engine` was +provisioned, by good practice rather than by rule; the rule now exists. + ## 11. Commercial expression - **11.1** Plan tiers are expressed *internally* as minimum levels. A tier may @@ -396,7 +432,9 @@ escalation. - A claim that another tenant **cannot** reach the customer's data requires **E4**. - A claim that deleted data **is gone** requires **R4**, or an erasure - horizon disclosed alongside it. + horizon disclosed alongside it. Where R4 is reached by key destruction, the + claim is defensible but not settled law (§4.5) — it may be made, and it may + not be made in language that implies a regulator has blessed it. ## 12. Methodology — analyze, establish, improve, guard @@ -424,6 +462,14 @@ Guarding must be designed for invisible failure, not for crashes. **Decision 13.1:** a level is claimed only with its evidence artifact present. This turns §6's accuracy rule from an honour system into a check. +**Decision 13.4 — an artifact must assert something achievable.** Draft-3's +noisy-neighbour evidence required proof that a saturating consumer "does not +breach" another's allowance. Shared infrastructure cannot provide that; the +risk is inherent and cannot be wholly removed. An artifact that can only fail, +or that passes by being run gently enough, is an overclaim wearing the costume +of evidence. Where a property cannot be guaranteed, the artifact measures and +records it instead. + **Decision 13.2 — evidence is of two kinds, and conflating them is an overclaim.** *Mechanical* evidence is a structural assertion a machine can make and belongs in CI. *Adversarial* evidence is semantic, requires setting up @@ -444,7 +490,7 @@ E2 evidence.** | **E3** | `FORCE ROW LEVEL SECURITY` on every tenant table; no `BYPASSRLS` on leased roles; probe that a session without the GUC reads nothing; probe that a wrong GUC reads nothing; `EXPLAIN` comparison | Mechanical | | **E4** | Per-tenant credential demonstrated unable to connect to another tenant's substrate | Mechanical | | **P1–P4** | Provisioning declaration plus the platform's isolation probes | Mechanical | -| **P1–P2 (noisy neighbour)** | One consumer saturating its connection or CPU allowance demonstrably does not breach another's | **Adversarial**, load-generated | +| **P1–P2 (noisy neighbour)** | A recorded baseline of per-consumer resource usage; a run in which one consumer saturates its declared allowance; evidence that the governance controls **bind** (the greedy consumer is held at its limits) and that the degradation co-residents experience is **measured, recorded and judged acceptable**; the aggregate headroom at time of measurement | **Adversarial**, load-generated, with a review date | | **R2** | Declared retention rendered; erasure horizon published and reported in the operator surface | Mechanical | | **R3** | Sweep evidence records: timestamp, dataset, identifiers removed, authorising policy reference | Mechanical | | **R4** | Erasure demonstrated across live data, backups and derived copies within the horizon | **Adversarial** | @@ -561,10 +607,19 @@ memory limit: 1Gi per consumer: 14 connections (12 runtime + 2 migration) ``` -**Connection ceiling: roughly six consumers.** Seven request 98 of 100 before -CNPG's instance manager, metrics exporter and reserved slots. Two are -provisioned. We are at roughly a third of capacity and the third request will -not feel like a scaling event. +**Connection ceiling: roughly six consumers — and this is the aggregate +noisy-neighbour bound, not a capacity statistic.** Seven consumers request 98 +of 100 before CNPG's instance manager, metrics exporter and reserved slots. +Every one of them is politely inside its declared 14-connection allowance; the +instance still fails. + +That distinction matters because our governance addresses the wrong shape. +Per-consumer `connection_limit`, `statement_timeout` and +`idle_in_transaction_session_timeout` guard well against **one greedy +consumer**. They do nothing about **the aggregate of many modest ones**, which +is the second and less intuitive noisy-neighbour failure and the one this +number describes. Two consumers are provisioned. We are at roughly a third of +the bound, and the third request will not feel like a scaling event. **Memory likely binds first.** 100 backends against 1Gi is ~10MB per backend. Connection exhaustion errors clearly; memory pressure OOM-kills and degrades @@ -634,6 +689,20 @@ minimum-level vocabulary alongside isolation. 10. **Engine neutrality** — the P ladder rests on a PostgreSQL property. State it engine-specifically and say so, or abstract it and risk a non-Postgres implementation that silently differs? +11. **Erasure versus audit** — `audit-core`: crypto-shredding a tenant's audit + records destroys the evidence the service exists to hold, and ADR-0001 §2 + deliberately built the role model so history could not be rewritten. The + usual resolution separates the *fact* of an event, retained, from its + *personal payload*, encrypted per subject and shreddable. Raised because a + naive "R4 everywhere" target would instruct the audit service to destroy + its own evidence. The answer is `audit-core`'s, not this framework's. +12. **Quality of service** — *owner needed.* The framework has no vocabulary + for saying one consumer's latency matters more than another's. + `tenant-engine` sits on `flex-auth`'s synchronous authorization path and + chose a 5s statement timeout for that reason; it shares an instance with + `audit-core`, which is not latency-critical. Nothing prioritises between + them. Either add a QoS dimension or state that all co-residents are equal + and latency-critical consumers must escalate to P2. **Routed elsewhere, deliberately.** The tenant identifier `tenant::` embeds headcount bands (`small`, `medium`, `large`) From 1c65257352f2a29600650dcbf594285e993f8f96 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 17 Aug 2026 15:10:22 +0200 Subject: [PATCH 6/7] tools: generate the artifact page from canon markdown The page and the ADR had already diverged once. They are now one source: the markdown is authoritative and the page is generated, never hand-edited. The renderer recognises conventions the document already uses rather than requiring extra markup, so the source stays a readable document. A table whose first column is **X0**/**X1** becomes a level ladder; a table headed Threat becomes the threat matrix; a table with a Kind column gets mechanical and adversarial chips; a table headed "E \ P" becomes the two-axis grid; ## N. headings build the section rail. Stdlib only, per the structure-not-tooling stance. A publishing step that needs its own toolchain is a publishing step that stops being run - the same reasoning tenant-engine used for its pin check. One real consequence beyond deduplication: the E x P matrix had existed only on the page, so the canonical document did not contain its own central diagram. It is now a table in the markdown, which is where it should always have been. Co-Authored-By: Claude Opus 5 --- Makefile | 7 + .../adr-008-multi-tenancy-model.md | 22 +- tools/artifact-style.css | 185 +++++++++ tools/render-artifact.py | 377 ++++++++++++++++++ 4 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 tools/artifact-style.css create mode 100644 tools/render-artifact.py diff --git a/Makefile b/Makefile index e1b1461..5a9ce85 100644 --- a/Makefile +++ b/Makefile @@ -193,3 +193,10 @@ agents-update: agents-validate: @echo "Validating agents..." @kaizen-agentic validate agents/ + +.PHONY: artifact-adr008 +artifact-adr008: + python3 tools/render-artifact.py canon/architecture/adr-008-multi-tenancy-model.md \ + --output build/adr-008.html \ + --title "Tenancy Posture" \ + --subtitle "A framework for describing, holding and improving multi-tenancy — including where we are not there yet." diff --git a/canon/architecture/adr-008-multi-tenancy-model.md b/canon/architecture/adr-008-multi-tenancy-model.md index 6e4273a..5795ed3 100644 --- a/canon/architecture/adr-008-multi-tenancy-model.md +++ b/canon/architecture/adr-008-multi-tenancy-model.md @@ -227,10 +227,24 @@ breach SHOULD target E3 or above. | **P3** | Dedicated cluster per tenant. | Business apps per `business-app-service-contract` §1.2 | | **P4** | P3 plus separate region or jurisdiction. | None | -**P0 → P1 → P2 does not raise the E level.** Those steps buy consumer -isolation, capacity predictability, independent retention and a smaller -operational blast radius. Only P3 makes E4 reachable. This is the most -misusable fact in the framework and §11 governs how it may be described. +Enforcement and placement are independent axes. Plotted together, with where +each service actually sits — parenthesised entries are targets or defaults +rather than current positions, and `—` marks a cell the coupling in §3.2 makes +unreachable: + +| E \ P | P0 | P1 | P2 | P3 | P4 | +|---|---|---|---|---|---| +| **E4** | — | — | — | (business app) | | +| **E3** | | (target) | | | | +| **E2** | | tenant-engine
audit-core | | | | +| **E1** | (absorbed repo) | | | | | +| **E0** | | | | | | + +**P0 → P1 → P2 is movement along the horizontal axis only.** Those steps buy +consumer isolation, capacity predictability, independent retention and a +smaller operational blast radius. They do not raise the tenant boundary by one +step. Only P3 makes E4 reachable. This is the most misusable fact in the +framework and §11 governs how it may be described. **Decision 4.4.1:** P1 is the default for platform services; P3 for client-facing business apps, as already ratified. A service unsure which it is diff --git a/tools/artifact-style.css b/tools/artifact-style.css new file mode 100644 index 0000000..ebf009d --- /dev/null +++ b/tools/artifact-style.css @@ -0,0 +1,185 @@ +:root{ + --paper:#EDEEF0; --surface:#F6F7F8; --surface-2:#E4E6E9; + --ink:#171D24; --ink-2:#4A5561; --ink-3:#737E8A; + --rule:#D3D7DC; --rule-strong:#B6BCC3; + --brass:#8A6A2E; --brass-soft:#EFE5CD; --brass-line:#C9AE74; + --clay:#8A3A2C; --clay-soft:#F2DFDA; + --l0:#DCE0E2; --l1:#B9C4C7; --l2:#8CA1A6; --l3:#567D84; --l4:#23555E; + --chip-fg:#F6F7F8; + --font-display:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; + --font-body:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; + --font-mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace; + --measure:66ch; +} +@media (prefers-color-scheme:dark){ + :root:not([data-theme="light"]){ + --paper:#12161A; --surface:#191E24; --surface-2:#222831; + --ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E; + --rule:#2A3138; --rule-strong:#3B444D; + --brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426; + --clay:#D08A76; --clay-soft:#3A211B; + --l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD; + --chip-fg:#12161A; + } +} +:root[data-theme="dark"]{ + --paper:#12161A; --surface:#191E24; --surface-2:#222831; + --ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E; + --rule:#2A3138; --rule-strong:#3B444D; + --brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426; + --clay:#D08A76; --clay-soft:#3A211B; + --l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD; + --chip-fg:#12161A; +} + +*{box-sizing:border-box} +body{ + margin:0; background:var(--paper); color:var(--ink); + font-family:var(--font-body); font-size:17px; line-height:1.62; + -webkit-font-smoothing:antialiased; +} +.wrap{max-width:1180px;margin:0 auto;padding:0 24px 96px} +.layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:56px;align-items:start} +@media (max-width:960px){.layout{grid-template-columns:1fr;gap:0}.rail{display:none}} + +/* ---------- rail ---------- */ +.rail{position:sticky;top:28px;padding-top:8px;font-family:var(--font-display);font-size:12px;line-height:1.5} +.rail ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px} +.rail a{color:var(--ink-3);text-decoration:none;display:flex;gap:9px} +.rail a:hover,.rail a:focus-visible{color:var(--brass)} +.rail .n{font-family:var(--font-mono);font-size:10px;color:var(--rule-strong);min-width:16px;padding-top:1px} +.rail .grp{margin-top:14px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--rule-strong)} + +/* ---------- header ---------- */ +header{padding:64px 0 40px;border-bottom:2px solid var(--ink);margin-bottom:44px} +.eyebrow{font-family:var(--font-mono);font-size:11.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:14px;margin-bottom:22px} +.eyebrow .stat{color:var(--clay)} +h1{font-family:var(--font-display);font-weight:800;letter-spacing:-.035em;line-height:.94;font-size:clamp(46px,9vw,92px);margin:0 0 6px;text-wrap:balance} +.sub{font-family:var(--font-display);font-weight:500;font-size:clamp(16px,2.4vw,21px);letter-spacing:-.01em;color:var(--ink-2);margin:0 0 30px;max-width:34ch;line-height:1.3} +.metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:20px 28px;border-top:1px solid var(--rule);padding-top:20px} +.metagrid dt{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px} +.metagrid dd{margin:0;font-family:var(--font-display);font-size:13.5px;line-height:1.45;color:var(--ink)} + +/* ---------- typography ---------- */ +section{margin-bottom:60px;scroll-margin-top:24px} +h2{font-family:var(--font-display);font-weight:750;letter-spacing:-.022em;font-size:clamp(24px,3.4vw,31px);line-height:1.12;margin:0 0 18px;text-wrap:balance;display:flex;gap:14px;align-items:baseline} +h2 .sn{font-family:var(--font-mono);font-size:12px;font-weight:400;color:var(--brass);letter-spacing:.06em;flex:none;padding-top:2px} +h3{font-family:var(--font-display);font-weight:700;font-size:16px;letter-spacing:-.008em;margin:34px 0 10px;color:var(--ink)} +p{margin:0 0 15px;max-width:var(--measure)} +ul,ol{max-width:var(--measure);margin:0 0 15px;padding-left:20px} +li{margin-bottom:7px} +strong{font-weight:600} +em{font-style:italic} +code{font-family:var(--font-mono);font-size:.855em;background:var(--surface-2);padding:1px 5px;border-radius:2px} +a{color:var(--brass)} +.lede{font-size:19px;line-height:1.55;color:var(--ink-2);max-width:60ch} + +/* ---------- devices ---------- */ +.callout{border-left:3px solid var(--brass);background:var(--brass-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)} +.callout p:last-child{margin-bottom:0} +.callout .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px} +.rule-quote{border-top:2px solid var(--ink);border-bottom:2px solid var(--ink);padding:26px 0;margin:28px 0;max-width:var(--measure)} +.rule-quote p{font-family:var(--font-display);font-weight:600;font-size:19px;line-height:1.38;letter-spacing:-.014em;margin:0;text-wrap:balance} +.hard{border-left:3px solid var(--clay);background:var(--clay-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)} +.hard .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--clay);display:block;margin-bottom:8px} +.hard p:last-child{margin-bottom:0} +.dec{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.08em;color:var(--brass);text-transform:uppercase} +.vec{font-family:var(--font-mono);font-size:.9em;font-weight:600;background:var(--surface-2);padding:2px 7px;border-radius:2px;white-space:nowrap;letter-spacing:.04em} + +/* ---------- tables ---------- */ +.scroll{overflow-x:auto;margin:0 0 24px;-webkit-overflow-scrolling:touch} +table{border-collapse:collapse;width:100%;min-width:520px;font-family:var(--font-display);font-size:13.5px;line-height:1.45} +th{text-align:left;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);font-weight:400;padding:0 16px 8px 0;border-bottom:1px solid var(--rule-strong);vertical-align:bottom} +td{padding:11px 16px 11px 0;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink-2)} +td:first-child{color:var(--ink);font-weight:600} +tbody tr:last-child td{border-bottom:none} +.lvl{font-family:var(--font-mono);font-weight:600;font-size:12px;letter-spacing:.04em;color:var(--ink)} + +/* ---------- ladders ---------- */ +.breakout{margin:34px 0 40px} +.bhead{display:flex;justify-content:space-between;align-items:baseline;gap:20px;border-bottom:1px solid var(--rule-strong);padding-bottom:9px;margin-bottom:22px;flex-wrap:wrap} +.bhead h3{margin:0;font-size:13px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--font-mono);font-weight:400;color:var(--ink-3)} +.bhead .note{font-family:var(--font-display);font-size:12.5px;color:var(--ink-3)} +.ladders{display:grid;gap:26px} +.ladder{display:grid;grid-template-columns:126px minmax(0,1fr);gap:18px;align-items:start} +@media (max-width:700px){.ladder{grid-template-columns:1fr;gap:10px}} +.ladder .pname{font-family:var(--font-display);font-weight:700;font-size:14px;letter-spacing:-.01em;padding-top:2px} +.ladder .pname span{display:block;font-family:var(--font-mono);font-size:10px;font-weight:400;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-top:3px} +.rungs{display:grid;gap:3px;grid-template-columns:repeat(5,minmax(0,1fr))} +@media (max-width:700px){.rungs{grid-template-columns:repeat(2,minmax(0,1fr))}} +.rung{padding:9px 10px 11px;background:var(--surface);border-top:4px solid var(--l0);min-width:0} +.rung.r1{border-top-color:var(--l1)} .rung.r2{border-top-color:var(--l2)} +.rung.r3{border-top-color:var(--l3)} .rung.r4{border-top-color:var(--l4)} +.rung .code{font-family:var(--font-mono);font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ink);display:block;margin-bottom:4px} +.rung .txt{font-family:var(--font-display);font-size:11.5px;line-height:1.34;color:var(--ink-2);display:block} +.rung.na{opacity:.42} + +/* ---------- matrix ---------- */ +.matrix-shell{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:stretch;margin-bottom:14px} +.ylab{writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);text-align:center;padding-bottom:22px} +.mgrid{display:grid;grid-template-columns:34px repeat(5,minmax(0,1fr));gap:3px} +.mcell{background:var(--surface);min-height:60px;padding:6px;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;min-width:0} +.mcell.tint1{background:color-mix(in srgb,var(--l1) 26%,var(--surface))} +.mcell.tint2{background:color-mix(in srgb,var(--l2) 26%,var(--surface))} +.mcell.tint3{background:color-mix(in srgb,var(--l3) 24%,var(--surface))} +.mcell.tint4{background:color-mix(in srgb,var(--l4) 22%,var(--surface))} +.mcell.void{background:repeating-linear-gradient(135deg,transparent,transparent 5px,var(--rule) 5px,var(--rule) 6px);opacity:.55} +.rlab,.clab{font-family:var(--font-mono);font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--ink-3);display:flex;align-items:center;justify-content:center} +.rlab{min-height:60px} +.clab{padding-top:7px;min-height:22px} +.pin{font-family:var(--font-mono);font-size:9.5px;font-weight:600;letter-spacing:.02em;background:var(--ink);color:var(--paper);padding:2px 5px;border-radius:2px;line-height:1.3;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.pin.ghost{background:transparent;color:var(--ink-2);border:1px dashed var(--rule-strong)} +.mnote{display:flex;gap:22px;flex-wrap:wrap;font-family:var(--font-display);font-size:12px;color:var(--ink-3);padding-top:6px} +.mnote .k{display:flex;align-items:center;gap:7px} +.sw{width:13px;height:13px;flex:none;background:var(--ink)} +.sw.g{background:transparent;border:1px dashed var(--rule-strong)} +.sw.v{background:repeating-linear-gradient(135deg,transparent,transparent 4px,var(--rule) 4px,var(--rule) 5px);border:1px solid var(--rule)} +@media (max-width:640px){ + .mgrid{grid-template-columns:28px repeat(5,minmax(0,1fr))} + .mcell{min-height:52px;padding:4px} + .pin{font-size:8px;padding:1px 3px} + .rlab{min-height:52px} +} + +/* ---------- methodology ---------- */ +.verbs{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule)} +.verb{background:var(--surface);padding:18px 18px 20px} +.verb h4{font-family:var(--font-display);font-weight:750;font-size:15px;margin:0 0 7px;letter-spacing:-.01em} +.verb p{font-family:var(--font-display);font-size:12.5px;line-height:1.46;color:var(--ink-2);margin:0;max-width:none} +.verb .step{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;color:var(--brass);display:block;margin-bottom:9px} + +/* ---------- questions ---------- */ +.qs{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--rule-strong)} +.q{display:grid;grid-template-columns:34px minmax(0,1fr) 170px;gap:18px;padding:16px 0;border-bottom:1px solid var(--rule);align-items:start} +@media (max-width:760px){.q{grid-template-columns:28px minmax(0,1fr);gap:12px}.q .owner{grid-column:2}} +.q .qn{font-family:var(--font-mono);font-size:11px;color:var(--brass);padding-top:3px} +.q .qt{font-family:var(--font-display);font-size:14px;line-height:1.48;color:var(--ink-2)} +.q .qt b{color:var(--ink);font-weight:700;display:block;margin-bottom:2px;font-size:14.5px} +.owner{font-family:var(--font-mono);font-size:10px;letter-spacing:.05em;color:var(--ink-3);padding-top:4px} +.owner .tag{display:inline-block;border:1px solid var(--rule-strong);padding:2px 7px;border-radius:2px} +.owner .tag.need{border-color:var(--clay);color:var(--clay)} + +/* ---------- misc ---------- */ +.numbers{font-family:var(--font-mono);font-size:12.5px;line-height:1.85;background:var(--surface);border-left:3px solid var(--l3);padding:16px 20px;margin:0 0 22px;overflow-x:auto;max-width:var(--measure)} +.numbers .v{color:var(--ink);font-weight:600} +.numbers .k{color:var(--ink-3)} +pre{font-family:var(--font-mono);font-size:12.5px;line-height:1.68;background:var(--surface);border-left:3px solid var(--rule-strong);padding:16px 20px;overflow-x:auto;margin:0 0 22px;max-width:var(--measure);color:var(--ink-2)} +.alt{border-bottom:1px solid var(--rule);padding:14px 0;max-width:var(--measure)} +.alt:last-of-type{border-bottom:none} +.alt b{font-family:var(--font-display);font-size:14px;display:block;margin-bottom:3px} +.alt p{font-size:14.5px;margin:0;color:var(--ink-2)} +.alt .verdict{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--clay)} +footer{border-top:2px solid var(--ink);margin-top:20px;padding-top:22px;font-family:var(--font-mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap} +.tm td,.tm th{text-align:center} +.tm td:first-child,.tm th:first-child{text-align:left} +.yes{color:var(--l4);font-weight:700} +.no{color:var(--clay);font-weight:700} +.kind{font-family:var(--font-mono);font-size:9px;letter-spacing:.09em;text-transform:uppercase;padding:2px 6px;border-radius:2px;white-space:nowrap;border:1px solid var(--rule-strong);color:var(--ink-3)} +.kind.adv{border-color:var(--clay);color:var(--clay)} +.routes{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule);margin:0 0 22px} +.route{background:var(--surface);padding:16px 18px} +.route h4{font-family:var(--font-display);font-weight:750;font-size:14px;margin:0 0 6px} +.route p{font-family:var(--font-display);font-size:12.5px;line-height:1.45;color:var(--ink-2);margin:0;max-width:none} +.route .tag{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px} +a:focus-visible,.rail a:focus-visible{outline:2px solid var(--brass);outline-offset:3px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/tools/render-artifact.py b/tools/render-artifact.py new file mode 100644 index 0000000..ecf2d06 --- /dev/null +++ b/tools/render-artifact.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Render a canon markdown document into a styled, self-contained artifact page. + +Single source of truth: the markdown. The page is generated, never hand-edited, +so the two cannot diverge. + +Design devices are recognised from conventions already present in the markdown +rather than from extra markup, so the source stays a readable document: + + * a table whose first column is `**X0**`/`**X1**`... renders as a level ladder + * a table whose first header cell is `Threat` renders as a threat matrix + * a table with a `Kind` column renders with mechanical/adversarial chips + * a table whose first header cell is `E \\ P` renders as the E x P matrix + * a blockquote renders as a pull quote + * `**Decision N.N...**` at the start of a paragraph renders as a decision + * `## N. Title` headings build the section rail + +Stdlib only, per the estate's structure-not-tooling stance: a publishing step +that needs its own toolchain is a publishing step that stops being run. + +Usage: + python3 tools/render-artifact.py --output \\ + [--title "Name"] [--subtitle "..."] +""" + +from __future__ import annotations + +import argparse +import html +import pathlib +import re +import sys + +STYLE = pathlib.Path(__file__).parent / "artifact-style.css" + +INLINE_CODE = re.compile(r"`([^`]+)`") +BOLD = re.compile(r"\*\*([^*]+)\*\*") +EM = re.compile(r"(? str: + """Escape, then apply inline markdown. Order matters: code first.""" + slots: list[str] = [] + + def stash(rendered: str) -> str: + slots.append(rendered) + return f"\x00{len(slots) - 1}\x00" + + text = INLINE_CODE.sub(lambda m: stash(f"{html.escape(m.group(1))}"), text) + text = html.escape(text, quote=False) + text = LINK.sub( + lambda m: f'{m.group(1)}', text + ) + text = BOLD.sub(r"\1", text) + text = EM.sub(r"\1", text) + for i, rendered in enumerate(slots): + text = text.replace(f"\x00{i}\x00", rendered) + return text + + +def split_frontmatter(source: str) -> tuple[dict, str]: + if not source.startswith("---\n"): + return {}, source + end = source.index("\n---\n", 4) + meta = {} + for line in source[4:end].splitlines(): + if ":" in line and not line.startswith((" ", "-")): + key, _, value = line.partition(":") + meta[key.strip()] = value.strip().strip('"') + return meta, source[end + 5 :] + + +def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]: + rows, i = [], start + while i < len(lines) and lines[i].lstrip().startswith("|"): + cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + if not all(set(c) <= set("-: ") for c in cells): + rows.append(cells) + i += 1 + return rows, i + + +# --- table renderers ------------------------------------------------------- + + +def render_ladder(rows: list[list[str]]) -> str: + """A level table becomes a stepped scale. Colour depth encodes strength.""" + body = rows[1:] + plane = LEVEL_CELL.match(body[0][0]).group(1) + names = { + "I": "Identity", "A": "Authorization", "E": "Enforcement", + "P": "Placement", "R": "Retention", + } + rungs = [] + for cells in body: + match = LEVEL_CELL.match(cells[0]) + if not match: + continue + n = int(match.group(2)) + text = cells[1] if len(cells) > 1 else "" + rungs.append( + f'
{plane}{n}' + f'{inline(text)}
' + ) + while len(rungs) < 5: + rungs.append('
—' + f'Ladder ends at {plane}{len(rungs) - 1}.
') + return ( + '
' + f'{names.get(plane, plane)}plane {plane}
' + f'
{"".join(rungs)}
' + ) + + +def render_threat(rows: list[list[str]]) -> str: + head = "".join(f"{inline(c)}" for c in rows[0]) + body = [] + for cells in rows[1:]: + tds = [f"{inline(cells[0])}"] + for c in cells[1:]: + cls = "yes" if "✓" in c else "no" if "✗" in c else "" + tds.append(f'{inline(c)}') + body.append(f"{''.join(tds)}") + return (f'
{head}' + f'{"".join(body)}
') + + +def render_matrix(rows: list[list[str]]) -> str: + """`E \\ P` table becomes the two-axis grid. Cells hold pins or markers.""" + cols = rows[0][1:] + cells = ['
'] + for cells_row in rows[1:]: + e = cells_row[0].strip("*") + level = int(e[1]) if len(e) > 1 and e[1].isdigit() else 0 + cells.append(f'
{html.escape(e)}
') + for value in cells_row[1:]: + v = value.strip() + if v == "—": + cells.append('
') + continue + tint = f" tint{level}" if level else "" + pins = "" + for entry in (p.strip() for p in v.split("
") if p.strip()): + ghost = " ghost" if entry.startswith("(") else "" + pins += f'{inline(entry.strip("()"))}' + cells.append(f'
{pins}
') + cells.append('
') + cells.extend(f'
{html.escape(c)}
' for c in cols) + cells.append("
") + return ( + '
Enforcement →
' + + "".join(cells) + + "
" + '
' + 'Where a service sits today' + 'Target or default' + 'Unreachable at this placement' + "
" + ) + + +def render_table(rows: list[list[str]]) -> str: + if not rows: + return "" + header = [c.strip() for c in rows[0]] + first = header[0].lower() + if first.replace(" ", "") in {"e\\p", "e\\p"}: + return render_matrix(rows) + if first == "threat": + return render_threat(rows) + kind_col = header.index("Kind") if "Kind" in header else None + # A level table is a ladder. The evidence table also leads with `Level` but + # carries a `Kind` column, and is a table of artifacts, not of rungs. + if first == "level" and kind_col is None and len(rows) > 1 and LEVEL_CELL.match(rows[1][0]): + return render_ladder(rows) + head = "".join(f"{inline(c)}" for c in header) + body = [] + for cells in rows[1:]: + tds = [] + for i, c in enumerate(cells): + if i == kind_col: + adv = "adv" if "adversarial" in c.lower() else "" + label = re.sub(r"[*_]", "", c).strip() + tds.append(f'{html.escape(label)}') + else: + tds.append(f"{inline(c)}") + body.append(f"{''.join(tds)}") + return (f'
{head}' + f'{"".join(body)}
') + + +# --- document --------------------------------------------------------------- + + +def render_body(markdown: str) -> tuple[str, list[tuple[str, str, str]]]: + lines = markdown.splitlines() + out: list[str] = [] + rail: list[tuple[str, str, str]] = [] + open_section = False + ladders_open = False + i = 0 + + def close_ladders() -> None: + nonlocal ladders_open + if ladders_open: + out.append("") + ladders_open = False + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped: + i += 1 + continue + + heading = HEADING.match(stripped) + if heading: + level, text = len(heading.group(1)), heading.group(2) + if level == 1: + i += 1 + continue + if level == 2: + close_ladders() + if open_section: + out.append("") + match = SECTION_NO.match(text) + if match: + num, title = match.group(1), match.group(2) + anchor = f"s{num}" + rail.append((anchor, num, title)) + out.append( + f'

' + f'{int(num):02d}{inline(title)}

' + ) + else: + anchor = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + rail.append((anchor, "·", text)) + out.append(f'

{inline(text)}

') + open_section = True + else: + close_ladders() + out.append(f"

{inline(text)}

") + i += 1 + continue + + if stripped.startswith("|"): + rows, i = parse_table(lines, i) + rendered = render_table(rows) + if 'class="ladder"' in rendered: + if not ladders_open: + out.append('
') + ladders_open = True + out.append(rendered) + else: + close_ladders() + out.append(rendered) + continue + + close_ladders() + + if stripped.startswith("```"): + block = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith("```"): + block.append(lines[i]) + i += 1 + out.append(f"
{html.escape(chr(10).join(block))}
") + i += 1 + continue + + if stripped.startswith(">"): + quote = [] + while i < len(lines) and lines[i].strip().startswith(">"): + quote.append(lines[i].strip().lstrip(">").strip()) + i += 1 + out.append(f'

{inline(" ".join(quote))}

') + continue + + if re.match(r"^[-*]\s+", stripped) or re.match(r"^\d+\.\s+", stripped): + ordered = bool(re.match(r"^\d+\.\s+", stripped)) + items = [] + while i < len(lines): + s = lines[i].strip() + if re.match(r"^[-*]\s+", s) or re.match(r"^\d+\.\s+", s): + items.append(re.sub(r"^([-*]|\d+\.)\s+", "", s)) + elif s and lines[i].startswith((" ", "\t")) and items: + items[-1] += " " + s + else: + break + i += 1 + tag = "ol" if ordered else "ul" + body = "".join(f"
  • {inline(t)}
  • " for t in items) + out.append(f"<{tag}>{body}") + continue + + if set(stripped) <= set("-") and len(stripped) >= 3: + i += 1 + continue + + para = [stripped] + i += 1 + while i < len(lines) and lines[i].strip() and not re.match( + r"^(\||>|```|#{1,4}\s|[-*]\s|\d+\.\s|---)", lines[i].strip() + ): + para.append(lines[i].strip()) + i += 1 + text = " ".join(para) + decision = DECISION.match(text) + if decision: + out.append( + f'

    {inline(decision.group(1))}' + f"{inline(decision.group(2))}

    " + ) + else: + out.append(f"

    {inline(text)}

    ") + + close_ladders() + if open_section: + out.append("
    ") + return "\n".join(out), rail + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("source", type=pathlib.Path) + ap.add_argument("--output", required=True, type=pathlib.Path) + ap.add_argument("--title", default=None) + ap.add_argument("--subtitle", default="") + args = ap.parse_args() + + meta, markdown = split_frontmatter(args.source.read_text()) + body, rail = render_body(markdown) + + title = args.title or meta.get("title", args.source.stem) + display = title.split(":")[0].strip() + eyebrow = " ".join( + f"{html.escape(v)}" + for k, v in ( + ("id", meta.get("id", "")), + ("status", f"{meta.get('status', '')} · {meta.get('revision', '')}".strip(" ·")), + ("date", meta.get("date", "")), + ) + if v + ) + rail_html = "".join( + f'
  • {n}{html.escape(t)}
  • ' + for a, n, t in rail + ) + + page = ( + f"{html.escape(display)}\n" + f"\n" + '
    ' + f'
    {eyebrow}generated from canon — do not edit
    ' + f"

    {html.escape(display)}

    " + + (f'

    {html.escape(args.subtitle)}

    ' if args.subtitle else "") + + '
    ' + f'' + f"
    {body}" + f'
    {html.escape(meta.get("id", ""))} · ' + f'{html.escape(meta.get("revision", ""))} · {html.escape(meta.get("status", ""))}' + "generated from the-custodian/canon
    " + "
    \n" + ) + args.output.write_text(page) + print(f"{args.output}: {len(rail)} sections, {len(page)} bytes") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 1674ea550d2012933077837fbe51ce5f5430328e Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 17 Aug 2026 15:40:59 +0200 Subject: [PATCH 7/7] ADR-008: relocate the multi-tenancy framework to NetKingdom canon Multi-tenancy is part of the IT-security framework NetKingdom provides, so it belongs beside the IAM Profile and the tenant-engine boundary contract rather than in the work-factory canon. Operator decision. Relocation surfaced two things a review would have caught embarrassingly late. NetKingdom's accepted platform-identity-security-architecture has used the word plane since July for a trust and deployment layer - bootstrap, platform control, tenant. This framework was using the same word for an independent dimension of concern. Two senses of one word in one canon is precisely the concept-ownership collision the estate is careful about, and the newcomer yields: they are now axes. The rename is also just better, since a posture vector is a point in five-dimensional space. That same document also disproves the framework's opening line. It has described the trust model, the tenant model and a capability progression since 2026-07-23, so the claim that the estate had never written down what it was building was wrong. The accurate and narrower claim is that nothing said how far a given service had got, or could hold several answers at once. Stub left behind so the ADR-008 identifier resolves. The renderer moved to policy-nexus, which owns publication. Co-Authored-By: Claude Opus 5 --- .../adr-008-multi-tenancy-model.md | 749 +----------------- tools/artifact-style.css | 185 ----- tools/render-artifact.py | 377 --------- 3 files changed, 21 insertions(+), 1290 deletions(-) delete mode 100644 tools/artifact-style.css delete mode 100644 tools/render-artifact.py diff --git a/canon/architecture/adr-008-multi-tenancy-model.md b/canon/architecture/adr-008-multi-tenancy-model.md index 5795ed3..a49bef1 100644 --- a/canon/architecture/adr-008-multi-tenancy-model.md +++ b/canon/architecture/adr-008-multi-tenancy-model.md @@ -1,741 +1,34 @@ --- id: ADR-008 type: architecture-decision-record -title: "Tenancy Posture: Five Planes, Graduated Levels, Declared Conformance" -status: proposed +title: "Multi-Tenancy Framework — relocated to NetKingdom" +status: superseded decided_by: Bernd Worsch date: "2026-08-17" -revision: "draft-4" -tags: ["architecture", "multi-tenancy", "isolation", "placement", "retention", "maturity", "tenant-engine", "flex-auth", "rapp-postgres", "scaling"] --- -# ADR-008: Tenancy Posture — Five Planes, Graduated Levels, Declared Conformance +# ADR-008 — relocated -## Status +Drafts 1–4 of the multi-tenancy framework were written here. On 2026-08-17 the +operator determined that multi-tenancy is part of the IT-security framework +NetKingdom provides, so the framework belongs in NetKingdom canon beside the +IAM Profile and the tenant-engine boundary contract. -**Proposed, draft-4.** +**It now lives at `net-kingdom/canon/standards/tenancy-posture_v0.1.md`.** -- **draft-1** proposed a single model with fixed characteristics. Rejected: it - could not describe a repo that is not there yet. -- **draft-2** reframed to graduated levels per plane. Externally corroborated - (§16), but four of its statements were wrong and one thing it needed was - missing. -- **draft-3** applied those corrections, added the retention plane, and - recorded an adoption stance. -- **draft-4** closes the two gaps draft-3 left open: `R4` had no mechanism - beyond waiting, and the noisy-neighbour evidence artifact asserted something - shared infrastructure cannot provide. Both were found by research, not by - review. +Two things changed on relocation: -Informed by five external research digests in `research/2026-08-17-adr008-*`, -which carry full citations for every external claim made here. +1. The five dimensions were renamed from **planes** to **axes**. NetKingdom's + accepted `platform-identity-security-architecture` already uses *plane* for + a trust and deployment layer (bootstrap / platform control / tenant), and + two senses of one word in one canon is a concept-ownership collision. +2. The opening claim that the estate "has never written down what it is + building" was corrected. That architecture document has described the trust + and tenant model since 2026-07-23. What was actually missing is a way to say + how far a given service has got. -Reviewed by nobody yet. §19 lists what each owner is being asked to accept. +This stub remains so that the ADR-008 identifier resolves rather than +dangling. It is not a second copy and must not be edited as one. -## 1. Context - -The estate has been building multi-tenancy for months and has never written -down what it is building. Five documents each cover a slice: - -| Document | Covers | Status | -|---|---|---| -| `iam-profile_v0.3` (NetKingdom) | Tenant identifier shape, `tenant_roles` claim, staleness rules | Ratified | -| `tenant-engine-boundary-contract_v0.1` (NetKingdom) | Who owns tenant records, roles, plan assignment | Ratified | -| `business-app-service-contract_v0.1` §1 (Custodian) | Business apps: instance-per-client, tenant-keyed data | Ratified | -| `rapp-postgres` ADR-0001 | Consumer + tenant isolation in PostgreSQL | Proposed, governs one repo | -| `rapp-postgres` ADR-0002 | Per-consumer retention and the erasure horizon | Proposed, governs one repo | -| `shared-platform-relational-storage_v0.1` | The stacked-boundary gap | Routed 2026-08-10, **still unratified** | - -Four failures follow. - -**The gap was diagnosed once and the fix stalled.** The v0.1 draft was written -to fill this hole and has sat unratified in neither canon directory. §20 -attaches a ratification path so this one does not join it. - -**Placement is owned by nobody.** `user-engine-pg` and `target-revenue-pg` are -dedicated; `apps-pg`, `net-kingdom-pg`, `platform-pg`, `state-hub-db` and -`forgejo-db` are shared. Both live, neither written down. `tenant-engine` -raised this with `railiance-platform` on 2026-08-16; unanswered. - -**Two contradictory defaults are already ratified.** Business apps get -instance-per-client; platform services pool. Nothing says which shape a new -service takes, and no definition separates the categories. - -**There is no honest way to describe a repo that is not there yet.** The estate -absorbs repos with weak or absent tenant separation. Today such a repo is -simply non-conformant, leaving it two bad options: misrepresent its posture, or -stay outside the framework. - -## 2. What this document is - -**A framework, not a model.** It specifies no single correct implementation. It -supplies terminology (§3, §4), a declaration (§5), a conformance rule (§6), -methodology (§12), and evidence definitions (§13). - -A service is conformant when its declared posture is accurate and its -trajectory recorded. A service is non-conformant when it claims a level it -cannot evidence — regardless of how high or low that level is. - -## 3. Five orthogonal planes - -"Is this multi-tenant?" is treated as one question. It is five, and they are -independent: - -| Plane | Question | Vocabulary owner | -|---|---|---| -| **Identity (I)** | How is a tenant named and validated? | `tenant-engine` / IAM Profile | -| **Authorization (A)** | How is a request bound to the tenants it may act for? | `flex-auth` | -| **Enforcement (E)** | Where, mechanically, is the tenant boundary enforced? | This framework | -| **Placement (P)** | Which substrate holds a tenant's data? | `railiance-platform` | -| **Retention (R)** | How long does data persist, and how is it erased? | The storage platform; policy by the consumer | - -Conflation produces errors today. `rapp-postgres`'s `PostgresConsumer` carries -`tenantIsolation: consumer-service-boundary` — an **E**-plane fact in a -**P**-plane artifact, reading as though storage enforces something it does not. -The "dedicated versus shared" argument mixes P (capacity, blast radius) with E -(correctness). - -The planes are separated *precisely so each may sit at a different level*. - -**Decision 3.1:** every document, declaration and plan tier that says -"isolation" MUST name which plane it means. - -**Decision 3.2:** the planes couple at their tops and the couplings MUST be -stated where they apply, not used to argue the planes are one: - -- `E4` is reachable only at `P3` or above. -- `R`'s erasure horizon is bounded below by `P` — on shared substrate, a - consumer's horizon is the instance maximum (§4.5). -- `R4` by key destruction is bounded by the **key boundary**, which is an - E-plane property. Shredding a single tenant's data requires the application - to encrypt under a per-tenant key before writing; the storage platform cannot - supply it. **Reaching the top of the retention ladder is not a retention - project.** - -**Decision 3.3 — scope.** The P and R ladders describe a service's **primary -datastore**. Caches, search indices, message queues and background jobs are -named leak surfaces in the external baselines and are assessed separately, not -covered by a posture vector. Saying so is honest; implying the vector covers -them would not be. - -## 4. Graduated levels - -Each plane carries an ordered ladder. Higher is stronger, not better: the right -level is the one a service can evidence and its risk warrants. - -### 4.1 Identity (I) - -| Level | State | -|---|---| -| **I0** | No tenant concept. Data not attributable to a tenant. | -| **I1** | A local tenant notion exists but is not canonical, **or** the tenant is taken from the request rather than from a verified token. | -| **I2** | Canonical identifiers, bound at the identity provider and carried as a verified claim; `tenant-engine` is the source of existence. | -| **I3** | I2 plus capability roles honoured, with live `tenant-engine` re-query for privileged, destructive, credential-vending or `aal2`-class decisions. | - -I1 now explicitly absorbs request-supplied tenant identifiers. "Never trust -client-supplied tenant IDs without validation" is a named anti-pattern; a -service reading the tenant from a header is at I1 however canonical the string. - -`business-app-service-contract` §2.1 sets app-local accounts as the v1 baseline -for business apps — a sanctioned low level with recorded triggers for moving -up. That is the pattern this framework generalises. - -### 4.2 Authorization (A) - -| Level | State | -|---|---| -| **A0** | No authorization, or tenant context not carried. | -| **A1** | Ad-hoc checks scattered through handlers. | -| **A2** | A single local authorization boundary; tenant context bound once, centrally. | -| **A3** | Decisions delegated to `flex-auth` as PDP, with live re-query where the IAM Profile requires it. | -| **A4** | A3 over a **standard** PDP interface (OpenID AuthZEN Authorization API 1.0), so the decision point is swappable and the enforcement point is not coupled to one engine's request shape. | - -A4 is new. `flex-auth` uses a bespoke `CheckRequest` and a bespoke action -vocabulary, with action strings copied verbatim between repos to avoid -re-derivation — exactly the coupling AuthZEN removes. The specification reached -Final in January 2026 and Keycloak shipped experimental support in May. We are -not wrong, we are pre-standard, and the ladder should have somewhere to go. - -**Internal service-to-service calls are in scope for this plane.** "Skipping -tenant validation for internal services" is a named anti-pattern, and our -estate is mostly internal calls — `flex-auth` calls `tenant-engine` -synchronously on the authorization path. A service identity acting on behalf of -a tenant must carry and revalidate tenant context to claim A2 or above. - -### 4.3 Enforcement (E) - -| Level | Mechanism | -|---|---| -| **E0** | None. Data not tenant-keyed; separation incidental or absent. | -| **E1** | Data tenant-keyed, filtering applied per query at call sites. | -| **E2** | Filtering centralised at a single service-side choke point binding authenticated identity to permitted tenants. | -| **E3** | E2 **plus** platform-assisted filtering: row-level security keyed on a tenant GUC set transaction-locally, or an equivalent enforced data-access layer. | -| **E4** | Structural: the credential a workload holds cannot address another tenant's data at all. Requires per-tenant credentials and per-tenant substrate. | - -**Correction from draft-2.** Draft-2 described E3 as something "the application -cannot trivially route around". That is false and it was this document -overclaiming in exactly the way §6 prohibits. Any session can re-issue `SET` on -a custom GUC, so an attacker with SQL execution can reset the tenant and read -across the boundary. What E3 buys is precise, and the ladder must say so: - -| Threat | E1 | E2 | E3 | E4 | -|---|:--:|:--:|:--:|:--:| -| A developer forgets a tenant predicate | ✗ | ✓ | ✓ | ✓ | -| A new code path bypasses the choke point | ✗ | ✗ | ✓ | ✓ | -| SQL injection reaching the connection | ✗ | ✗ | ✗ | ✓ | -| The application process is compromised | ✗ | ✗ | ✗ | ✓ | - -E3 is a strong control against **accident** — the common case, and the one that -causes real breaches — and no control at all against **compromise**. Only E4 -holds against both, because the credential itself cannot address another -tenant's data. - -**Correction: E3 layers on E2, it does not replace it.** External practice -treats application-layer and database-layer filtering as complementary. A -service that dropped its choke point on reaching E3 would be *worse* off, since -E3 fails open under injection. Claiming E3 therefore requires the E2 evidence -artifact as well. - -**Correction: the GUC is set transaction-locally.** Draft-2 said "at pool -checkout", which is session scope and the wrong instrument. Under a pooler in -statement mode, `SET` leaks between clients and returns other tenants' rows — -a failure that appears only under production concurrency and produces no error. -Use `SET LOCAL` inside an explicit transaction. - -**Platform enforcement is a platform obligation.** Reaching E3 requires the -storage platform to *offer* the mechanism: provisioned policies, a documented -GUC contract, and a probe. Where a consumer wants E3 and the platform has not -supplied it, the gap is the platform's. §19.6 asks `rapp-postgres` to define -that contract, which must carry `FORCE ROW LEVEL SECURITY` on every tenant -table (without it the table owner bypasses policies silently, and ADR-0001 -already established that our migration role owns the tables it creates), no -`BYPASSRLS` on leased roles, `SECURITY INVOKER` for ordinary logic, and an -`EXPLAIN` comparison because RLS disables functional indexes built on -non-leakproof functions. - -**Default expectation** for a new platform service: E2 at first serve, E3 -recorded as target. Services whose cross-tenant exposure would be a reportable -breach SHOULD target E3 or above. - -### 4.4 Placement (P) - -| Level | Shape | Live occupants | -|---|---|---| -| **P0** | Shares a database with another consumer. | None sanctioned; the state absorbed repos arrive in. | -| **P1** | Database per consumer, shared cluster. | `audit-core`, `tenant-engine` on `platform-pg` | -| **P2** | Dedicated cluster per consumer. | `user-engine-pg`, `target-revenue-pg` | -| **P3** | Dedicated cluster per tenant. | Business apps per `business-app-service-contract` §1.2 | -| **P4** | P3 plus separate region or jurisdiction. | None | - -Enforcement and placement are independent axes. Plotted together, with where -each service actually sits — parenthesised entries are targets or defaults -rather than current positions, and `—` marks a cell the coupling in §3.2 makes -unreachable: - -| E \ P | P0 | P1 | P2 | P3 | P4 | -|---|---|---|---|---|---| -| **E4** | — | — | — | (business app) | | -| **E3** | | (target) | | | | -| **E2** | | tenant-engine
    audit-core | | | | -| **E1** | (absorbed repo) | | | | | -| **E0** | | | | | | - -**P0 → P1 → P2 is movement along the horizontal axis only.** Those steps buy -consumer isolation, capacity predictability, independent retention and a -smaller operational blast radius. They do not raise the tenant boundary by one -step. Only P3 makes E4 reachable. This is the most misusable fact in the -framework and §11 governs how it may be described. - -**Decision 4.4.1:** P1 is the default for platform services; P3 for -client-facing business apps, as already ratified. A service unsure which it is -must resolve that first (§19.4). - -**Decision 4.4.2 — placement scopes to data substrate.** Identity-provider -placement (realm-per-tenant versus Organizations) is the same silo/pool -decision on a different substrate, is live in our estate, and is undecided. -Realm-per-tenant carries a stated ceiling around 5–20 tenants, far below our -target. Recorded here as a parallel question (§19.7), not folded into P. - -### 4.5 Retention and erasure (R) - -New in draft-3. Implemented abstractly by the storage platform for any dataset; -policy is built on top of that interface by the consumer or its governance -layer. Reference implementation: `rapp-postgres` ADR-0002. - -| Level | State | -|---|---| -| **R0** | No retention or deletion position. Data kept indefinitely by default; no deletion path exists. | -| **R1** | Platform default retention applies (N=30 days). The consumer has declared no requirement. | -| **R2** | Retention declared as N days per dataset; the **erasure horizon** is published, and the consumer makes no promise shorter than it. | -| **R3** | Policy-driven deletion: the consumer or its governance layer declares what is due, the platform sweeps whole datasets on that instruction and evidences each run. | -| **R4** | Verified erasure: data proven unrecoverable across live storage, backups and derived copies, by one of the two routes below. | - -**R4 has two routes and a service MUST name which one it uses.** - -| Route | Mechanism | Cost | -|---|---|---| -| **Horizon-elapsed** | Wait out the published erasure horizon; the data ages out of every retained copy. | Available to everyone, proves little, and the wait is set by a co-resident's retention requirement rather than your own. | -| **Key-destroyed** | Encrypt per entity, then destroy the key. Retained copies survive but are unreadable. | Requires per-entity keys, strong encryption, and an auditable destruction record. Immediate. | - -**Regulatory standing of the key-destroyed route, stated carefully because -overclaiming here is worse than anywhere else in this framework.** Data -protection authorities have accepted key destruction as erasure where physical -deletion would be manifestly disproportionate, and the practice is recognised -under conditions — strong encryption, irreversible destruction, and an auditable -record of it. **The EDPB has not formally endorsed it as Article 17 erasure.** A -service reaching R4 by key destruction is making a defensible claim, not a -settled one, and must say so rather than reporting a clean "deleted". - -Three further properties. - -**The erasure horizon is the interval between deleting data and it ceasing to -be recoverable from anything the platform holds.** Deleting a row does not -remove it from yesterday's backup. With an N-day window, deleted data remains -recoverable for N days. That is the difference between "deleted" and "erased" -and the estate had never written it down. - -**On shared substrate, retention is not per-consumer.** Physical backup is -instance-wide — one WAL stream, one window — so the instance retention is -*derived* as the maximum across co-resident consumers, and every consumer's -horizon is that maximum. A consumer declaring 7 days beside one declaring 90 -gets 90. This is the retention analogue of ADR-0001's blast-radius disclosure: -state the coupling rather than imply an isolation that is not there. - -**Retention is therefore a placement trigger.** A consumer needing a horizon -shorter than the instance floor cannot have one at P1. It moves to P2 for a -reason with nothing to do with performance — which is exactly why it needs -recording, since nobody looks for a retention argument when reviewing -placement. - -Deletion splits mechanism from policy. The platform deletes whole **datasets** -on instruction and records an opaque policy reference it never interprets, so -every deletion traces to what authorised it. Rows are not a dataset: row expiry -is the consumer's own DML under its migration lease. Dropping a consumer's -whole database is an operator-gated offboarding step, never a scheduled one. - -## 5. The posture vector - -A service states one level per plane, plus a target, a date, and any placement -exceptions: - -```yaml -tenancy: - current: { I: 2, A: 3, E: 2, P: 1, R: 1 } - target: { I: 2, A: 3, E: 3, P: 1, R: 2 } - reviewed: "2026-08-17" - gap: - E: "Choke point exists and is tested; RLS not provisioned. Blocked on - rapp-postgres publishing the GUC contract. Target Q4." - R: "Retention declared; erasure horizon not yet published to consumers." -``` - -**Placement exceptions.** Draft-2 assigned one P level per service, which -cannot express the vertically partitioned model — most tenants pooled, some -dedicated — that §11's isolation tiers require. A tier requiring `P2` bought by -three tenants would put the service at two levels at once, forcing an over- or -under-claim. Placement is therefore declared as a default plus exceptions: - -```yaml - placement_exceptions: - - tenants: ["tenant:enterprise:*"] - P: 3 - reason: "isolation tier; see adaptive-pricing tier definition" -``` - -A service with exceptions must be able to say which tenants are on which -substrate. That mapping is a first-class artifact, not archaeology. - -Worked examples, best-effort and subject to owner correction: - -| Service | Current | Notes | -|---|---|---| -| `tenant-engine` | `I2 A3 E2 P1 R1` | Moving to P1 under TEN-WP-0009; retention declared, horizon not yet published. | -| `audit-core` | `I2 A3 E2 P1 R1` | Holds audit evidence, so both E3 and R2 are urgent targets. | -| A newly absorbed repo | `I1 A1 E1 P0 R0` | Conformant **if declared**, with a recorded path. | - -**Decision 5.1:** the posture vector is declared in the repo, not in the hub, -consistent with local-files-are-source-of-truth. - -## 6. Conformance is accuracy, not altitude - -> **A service is conformant when its declared posture is accurate, its target -> is recorded, and it does not claim a level it cannot evidence. It is -> non-conformant when it overclaims — at any altitude.** - -- Declaring `E0` is conformant. Concealing `E0` is not. -- A repo may be absorbed at any posture. It may not be absorbed silently. -- No service is blocked from the estate for being low on a ladder. Services MAY - be blocked from *specific work* — serving a tenant grouping, holding a data - class, carrying a plan tier — by requirements expressed as minimum levels. -- Downgrading is permitted and must be declared. A regression found by guarding - is a defect; a regression declared in advance is a decision. - -Without the plane separation, "not rigorous about tenant separation" is one -verdict a repo passes or fails. With it, the same repo is `I1 A1 E1 P0 R0` with -a path — a plan, not an indictment. - -## 7. Portability across placement levels - -Movement between P levels must be operational, not a rebuild: - -- Connect by injected credential only — no cluster, host, namespace or database - name in source. -- Own a whole database, never tables inside someone else's. -- Idempotent schema creation. -- No cross-database joins or co-location assumptions. - -**Decision 7.1:** mandatory at P1 and above. At P3, SHOULD rather than MUST — a -per-client instance that never moves is not misconformant for naming its own -database. - -## 8. Placement triggers - -Recorded at provisioning time: noisy neighbour on a latency-critical path; a -compliance or residency requirement; a plan tier requiring a higher minimum; an -erasure horizon that no longer fits (§4.5); connection or memory ceiling -reached. - -**Decision 8.1:** triggers MUST be *monitored*, not merely recorded. A trigger -in a YAML comment nobody re-reads is documentation, not control. - -**Decision 8.2:** placement policy ownership is proposed to -`railiance-platform`, **co-signed by `adaptive-pricing`**. Tenancy model -selection is a commercial decision as much as a technical one; an -operations-shaped repo should not hold it alone. - -## 9. Credentials as a tenancy control - -Short-lived leased credentials re-read at connection checkout, with -overlap-first rotation, bound the residual risk at every E level below E4: a -leaked credential expires rather than persisting. Stronger than the industry -norm of a long-lived per-service secret. - -**Decision 9.1:** static long-lived database credentials are not a sanctioned -path for any service above E0. - -## 10. Blast radius must be published - -**Decision 10.1:** every platform holding consumer data MUST publish, in -concrete terms, what a leaked runtime credential can and cannot reach at the -levels it operates. `rapp-postgres` ADR-0001 §5 is the reference. Where the -model cannot provide a guarantee, the platform says so and names the -escalation. - -**Decision 10.2 — quotas are disclosed, not discovered.** The same obligation -extends from what a leaked credential can reach to what the platform will -refuse to do for you. Every consumer MUST be told, at provisioning, the -throttles and quotas enforced against it — connection limits, statement -timeouts, idle-transaction timeouts — and told again when they change. A -consumer learning its statement timeout by hitting it in production is a -disclosure failure, not a consumer bug. This is how `tenant-engine` was -provisioned, by good practice rather than by rule; the rule now exists. - -## 11. Commercial expression - -- **11.1** Plan tiers are expressed *internally* as minimum levels. A tier may - require `E3 P2 R2`; it need not print that anywhere customer-facing. -- **11.2** Marketing and product language is free. No requirement to expose - level labels or this document. "Dedicated infrastructure", "isolated - tenancy", "private instance" all remain available. -- **11.3** The constraint is on **evidence, not vocabulary**. A customer-facing - isolation, availability or retention claim must map to a minimum level the - delivering service actually holds, recorded once when the tier is defined. - The review is internal and happens at tier definition — not per campaign. -- **11.4** Two hard lines, because these reach contracts and compliance - questionnaires: - - A claim that another tenant **cannot** reach the customer's data requires - **E4**. - - A claim that deleted data **is gone** requires **R4**, or an erasure - horizon disclosed alongside it. Where R4 is reached by key destruction, the - claim is defensible but not settled law (§4.5) — it may be made, and it may - not be made in language that implies a regulator has blessed it. - -## 12. Methodology — analyze, establish, improve, guard - -**Analyze.** Assess a repo against the ladders; produce `tenancy.current` with -reasoning recorded. Applies to new and absorbed services alike. - -**Establish.** Declare the target and gap. The target is set by data class, -tenant groupings served and plan tiers carried — not by ambition. - -**Improve.** Move one plane at a time. Raising P while leaving E untouched is -the characteristic misstep. - -**Guard.** Verify continuously that the declared posture holds — **against the -service's own declaration**, not a universal maximum. Nobody must prove every -service is at E4; the check is that none is below what it declared. - -Regression found by guarding is a defect; regression declared in advance is a -decision. The estate has been bitten twice by silent pin rollbacks producing -ordinary-looking 403s and 404s rather than errors. Posture regression looks the -same — an RLS context leak returns correct-looking rows for the wrong tenant. -Guarding must be designed for invisible failure, not for crashes. - -## 13. Evidence per level - -**Decision 13.1:** a level is claimed only with its evidence artifact present. -This turns §6's accuracy rule from an honour system into a check. - -**Decision 13.4 — an artifact must assert something achievable.** Draft-3's -noisy-neighbour evidence required proof that a saturating consumer "does not -breach" another's allowance. Shared infrastructure cannot provide that; the -risk is inherent and cannot be wholly removed. An artifact that can only fail, -or that passes by being run gently enough, is an overclaim wearing the costume -of evidence. Where a property cannot be guaranteed, the artifact measures and -records it instead. - -**Decision 13.2 — evidence is of two kinds, and conflating them is an -overclaim.** *Mechanical* evidence is a structural assertion a machine can make -and belongs in CI. *Adversarial* evidence is semantic, requires setting up -separate tenant contexts and comparing responses, and carries a review date -rather than a green build. Cross-tenant findings are the category external -testing practice identifies as needing human review. **A passing CI run is not -E2 evidence.** - -| Level | Evidence | Kind | -|---|---|---| -| **I2** | Identifiers validated against the vocabulary; rejection test for a malformed id; binding shown to come from a verified token | Mechanical | -| **I3** | Live re-query demonstrated on an `aal2`-class path; cached-claim path shown unused there | Mechanical | -| **A2** | Choke point identified; test that an unbound request is refused | Mechanical | -| **A3** | Live decision with a denial observed at the endpoint, not only at the decision surface | Mechanical | -| **A4** | Decision served over the standard interface; a second PDP substituted without PEP change | Mechanical | -| **E1** | Every tenant-owned table carries the tenant key | Mechanical | -| **E2** | Choke point identified; identity bound to tenant A demonstrably cannot read tenant B | **Adversarial**, with a review date | -| **E3** | `FORCE ROW LEVEL SECURITY` on every tenant table; no `BYPASSRLS` on leased roles; probe that a session without the GUC reads nothing; probe that a wrong GUC reads nothing; `EXPLAIN` comparison | Mechanical | -| **E4** | Per-tenant credential demonstrated unable to connect to another tenant's substrate | Mechanical | -| **P1–P4** | Provisioning declaration plus the platform's isolation probes | Mechanical | -| **P1–P2 (noisy neighbour)** | A recorded baseline of per-consumer resource usage; a run in which one consumer saturates its declared allowance; evidence that the governance controls **bind** (the greedy consumer is held at its limits) and that the degradation co-residents experience is **measured, recorded and judged acceptable**; the aggregate headroom at time of measurement | **Adversarial**, load-generated, with a review date | -| **R2** | Declared retention rendered; erasure horizon published and reported in the operator surface | Mechanical | -| **R3** | Sweep evidence records: timestamp, dataset, identifiers removed, authorising policy reference | Mechanical | -| **R4** | Erasure demonstrated across live data, backups and derived copies within the horizon | **Adversarial** | - -**Decision 13.3:** the E2, E3 and noisy-neighbour artifacts do not exist -anywhere in the estate today. `rapp-postgres` runs 15 adversarial probes, all -against the *consumer* boundary, none against the tenant boundary inside a -consumer. Externally, what this framework calls a tenant boundary failure is -**Broken Object Level Authorization** — OWASP API1, top of the API Security Top -10 since that list launched, and the most commonly exploited API vulnerability -in published assessments. We have no coverage for the highest-ranked risk in -our class of system. §19.3 seeks an owner. - -## 14. Adoption stance — structure, not tooling - -**Decision 14.1:** external research is design input. This estate adopts -published standards and structural patterns; it does not adopt tooling unless -that tooling is an established industry standard with broad application. -Everything else is built ground-up, so it can be optimised and refactored as -the estate sees fit. - -| Class | Stance | -|---|---| -| Security baselines (OWASP Multi-Tenant Security Cheat Sheet, API Security Top 10) | Adopt as the external reference our ladders answer to | -| Standards bodies (OpenID AuthZEN 1.0) | Adopt — this is what A4 is | -| Reference taxonomies (Azure tenancy models, AWS SaaS Lens, cell architecture) | Adopt as structure | -| Engine behaviour (PostgreSQL RLS mechanics) | Facts, not tooling | -| Third-party analyzers and test frameworks | **Do not adopt.** Take their rule taxonomies as checklists for probes we write ourselves | - -The practical effect is small and good: `rapp-postgres` already owns a -ground-up probe harness — bash and psql, no dependency tree — that found four -real defects in its own provisioning SQL. The evidence artifacts in §13 become -new probes in a tool we control. One idea worth reimplementing from the -external survey is **policy-diff classification**: labelling a change to an -enforcement policy as safe or breaking *before* it lands. - -## 15. Alternatives considered - -**One fixed model with a single set of characteristics** (draft-1). *Rejected:* -cannot describe a repo that is not there yet, forcing absorbed repos to -misrepresent their posture or stay outside. A framework that can only describe -its own end state is not a framework. - -**A maturity model with a single overall level.** *Rejected:* collapses the -plane separation. A service strong on identity and weak on enforcement has a -specific, actionable gap; one composite score hides it and invites averaging. - -**Prohibiting row-level security** (draft-2's inherited position). *Rejected in -draft-2, refined in draft-3:* RLS is a real rung against the common threat. The -error was never RLS — it was describing E3 in E4's language. - -**Schema-per-consumer in one database.** *Rejected:* `pg_catalog` is readable -per-database, so every co-resident enumerates every other's table and column -names regardless of grants. Retained as a describable state, never a target. - -**Mandating E4 for everyone.** *Rejected:* the tenant taxonomy includes -`consumer` (private individuals) and `family`. A cluster per private individual -is economically impossible; the taxonomy is itself evidence pooling is -required. - -**Per-consumer physical backup retention.** *Rejected:* CNPG retention is a -property of the instance's WAL archive. There is no mechanism, and claiming it -would be a fabricated guarantee. Hence the derived maximum in §4.5. - -**Platform-scheduled row expiry.** *Rejected:* requires the platform to hold -DML authority over consumer schemas and interpret consumer data semantics, both -forbidden by ADR-0001. The consumer's migration lease is the correct -instrument. - -**Leaving each repo to its own model.** *Rejected:* the status quo, which -produced two contradictory ratified defaults and an unowned placement question. - -## 16. Held against outside practice - -**The graduated reframe is corroborated, not invented here.** Microsoft's -tenancy-model guidance states it almost verbatim: *"Instead of viewing -isolation as a discrete property, consider it a spectrum. You can deploy -components of your architecture that are more isolated or less isolated than -other components in the same architecture."* The same guidance derives our E↔P -coupling independently — shared deployment means enforcement lives in -application code; dedicated deployment means it is structural. - -**Stronger than typical.** Most multi-tenancy literature models one boundary, -tenant-to-tenant. This estate has **two stacked boundaries**: platform-service -to platform-service, and tenant to tenant inside a consumer. Naming them -separately and refusing to enforce both with one mechanism is uncommon and -correct. Graduated per-plane levels also beat the silo/pool/bridge trichotomy, -which is approximately our P plane with the other four missing — which is why -it cannot express "pooled infrastructure, structurally enforced boundary". - -**Weaker than typical.** The pool model's standard mitigation is a *verified* -enforcement layer every service is demonstrably routed through. We have the -concept and none of the verification (§13.3). - -**Adopted without naming it.** Short-lived leased credentials re-read at -checkout beat the long-lived-secret norm. §9 promotes it to a tenancy control. - -**Still unexplored.** Neither P nor R describes a **cell** — a slice of -infrastructure with a *fixed maximum size*, sized so one cell's failure is -survivable and cell count scales linearly. `platform-pg` is, in these terms, an -uncapped cell: §17 computes a ceiling and nothing enforces it (§19.8). - -Sources: the four research digests in `research/2026-08-17-adr008-*`, which -carry full citations for every claim in this section. - -## 17. Scaling demands - -Measured against the live `platform-pg` specification, not estimated. - -``` -instances: 1 (no HA; single-node rail) -max_connections: 100 -memory limit: 1Gi -per consumer: 14 connections (12 runtime + 2 migration) -``` - -**Connection ceiling: roughly six consumers — and this is the aggregate -noisy-neighbour bound, not a capacity statistic.** Seven consumers request 98 -of 100 before CNPG's instance manager, metrics exporter and reserved slots. -Every one of them is politely inside its declared 14-connection allowance; the -instance still fails. - -That distinction matters because our governance addresses the wrong shape. -Per-consumer `connection_limit`, `statement_timeout` and -`idle_in_transaction_session_timeout` guard well against **one greedy -consumer**. They do nothing about **the aggregate of many modest ones**, which -is the second and less intuitive noisy-neighbour failure and the one this -number describes. Two consumers are provisioned. We are at roughly a third of -the bound, and the third request will not feel like a scaling event. - -**Memory likely binds first.** 100 backends against 1Gi is ~10MB per backend. -Connection exhaustion errors clearly; memory pressure OOM-kills and degrades -every co-resident at once. - -**E3 and pooling.** *Corrected from draft-2, which had this backwards.* -Transaction-scoped context (`SET LOCAL` inside an explicit transaction) is what -makes E3 **safe** under a pooler. Statement-level pooling is what breaks it, -serving other tenants' rows under concurrency with no error. E3 constrains -which pooling mode is available, not whether pooling is available. - -**Retention consumes the volume.** WAL accumulates with the window, and §4.5 -makes the window the maximum across consumers. A consumer declaring a long -retention extends everyone's horizon *and* everyone's storage draw against a -20Gi volume. - -**Restore time couples all consumers.** Physical backup is instance-wide, so a -consumer's RTO is a function of *total* instance size, not its own. - -**No P1 tenant has HA.** `instances: 1` means a tier promising uptime cannot be -satisfied at P1 as built — an availability floor belongs in §11's -minimum-level vocabulary alongside isolation. - -## 18. Consequences - -- The estate gains one vocabulary and a way to be honest about partial - adoption. -- Absorbed repos get a described state and a path instead of a failing grade. -- `tenantIsolation` in `PostgresConsumer` is revealed as a mislabelled field. -- The verification problem becomes tractable: guard against declaration. -- Draft-2's RLS prohibition is reversed and its E3 description corrected; - `rapp-postgres` acquires an obligation to define and offer the mechanism. -- Adding a consumer with long retention **silently extends everyone's erasure - horizon**. This must reach the consumer review checklist, not only this - document. -- A service selling an isolation tier must maintain a tenant→substrate mapping - it does not have today. -- Nothing here changes a running system. - -## 19. Open questions - -1. **`tenantIsolation` field** — `rapp-postgres`: rename to name its plane and - carry a level (`tenancy.E: 2`), or move it out of the storage declaration. -2. **Placement ownership** — `railiance-platform` with `adaptive-pricing`: - accept the ladder, triggers and the §8.1 monitoring obligation; appoint a - recorded placement owner per workload. -3. **E2, E3 and noisy-neighbour evidence** — *owner needed.* Now three - artifacts of two kinds: E3 and noisy-neighbour are buildable as probes in - the existing harness; E2 is adversarial and needs a review cadence. Both - `audit-core` and `tenant-engine` have declined fleet-scope work on correct - boundary reasoning, so this needs appointing. Highest-severity gap. -4. **Business app vs platform service** — Custodian canon: a classification - rule. Candidate: reuse `repo-classification-standard_v1.0`. -5. **Tier → minimum level mapping** — `adaptive-pricing` and `tenant-engine`: - required only for tiers making isolation, availability or retention claims. -6. **The E3 mechanism** — `rapp-postgres`: publish the GUC contract with the - `FORCE`/`BYPASSRLS`/`SECURITY INVOKER`/`EXPLAIN` requirements in §4.3. -7. **Identity-provider placement** — owner of `key-cape`: realm-per-tenant or - Organizations? Realm-per-tenant's ~5–20 tenant ceiling is below our target. -8. **Cell sizing** — reframed from "should we adopt cells" to **"what is - `platform-pg`'s declared maximum size, and what is the overflow target?"** - The connection ceiling forces this whether or not we adopt the vocabulary. -9. **Retention floor and ceiling** — should `backupRetentionDays` have a - platform minimum (so a consumer asking for 1 day gets a validation error - rather than a quiet disappointment) and a maximum (so nobody exhausts the - volume)? -10. **Engine neutrality** — the P ladder rests on a PostgreSQL property. - State it engine-specifically and say so, or abstract it and risk a - non-Postgres implementation that silently differs? -11. **Erasure versus audit** — `audit-core`: crypto-shredding a tenant's audit - records destroys the evidence the service exists to hold, and ADR-0001 §2 - deliberately built the role model so history could not be rewritten. The - usual resolution separates the *fact* of an event, retained, from its - *personal payload*, encrypted per subject and shreddable. Raised because a - naive "R4 everywhere" target would instruct the audit service to destroy - its own evidence. The answer is `audit-core`'s, not this framework's. -12. **Quality of service** — *owner needed.* The framework has no vocabulary - for saying one consumer's latency matters more than another's. - `tenant-engine` sits on `flex-auth`'s synchronous authorization path and - chose a 5s statement timeout for that reason; it shares an instance with - `audit-core`, which is not latency-critical. Nothing prioritises between - them. Either add a QoS dimension or state that all co-residents are equal - and latency-critical consumers must escalate to P2. - -**Routed elsewhere, deliberately.** The tenant identifier -`tenant::` embeds headcount bands (`small`, `medium`, `large`) -that change as a tenant grows, contradicting the consensus that identifiers -should not encode mutable attributes. That is a critique of ADR-0013, not of -this framework, and belongs to `tenant-engine` and NetKingdom canon. Folding it -in here would overreach. - -## 20. Ratification path - -1. Reviewed by `tenant-engine`, `flex-auth`, `rapp-postgres`, - `railiance-platform` and `adaptive-pricing` against §19. -2. Each publishes its own posture vector (§5) as part of review. **The - framework is validated by whether it can describe them accurately** — if a - repo cannot express itself in these five ladders, the ladders are wrong and - this document changes, not the repo. -3. On acceptance, **supersedes** the routing of - `rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1-draft.md`, - whose §§3–8 are absorbed here. That draft is withdrawn rather than left - pending. -4. On acceptance, `rapp-postgres` ADR-0001 and ADR-0002 move to `accepted` and - are annotated as the PostgreSQL implementation of the E, P and R ladders. +The publication renderer that lived in `tools/` moved to `policy-nexus`, which +owns publication. diff --git a/tools/artifact-style.css b/tools/artifact-style.css deleted file mode 100644 index ebf009d..0000000 --- a/tools/artifact-style.css +++ /dev/null @@ -1,185 +0,0 @@ -:root{ - --paper:#EDEEF0; --surface:#F6F7F8; --surface-2:#E4E6E9; - --ink:#171D24; --ink-2:#4A5561; --ink-3:#737E8A; - --rule:#D3D7DC; --rule-strong:#B6BCC3; - --brass:#8A6A2E; --brass-soft:#EFE5CD; --brass-line:#C9AE74; - --clay:#8A3A2C; --clay-soft:#F2DFDA; - --l0:#DCE0E2; --l1:#B9C4C7; --l2:#8CA1A6; --l3:#567D84; --l4:#23555E; - --chip-fg:#F6F7F8; - --font-display:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; - --font-body:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; - --font-mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace; - --measure:66ch; -} -@media (prefers-color-scheme:dark){ - :root:not([data-theme="light"]){ - --paper:#12161A; --surface:#191E24; --surface-2:#222831; - --ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E; - --rule:#2A3138; --rule-strong:#3B444D; - --brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426; - --clay:#D08A76; --clay-soft:#3A211B; - --l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD; - --chip-fg:#12161A; - } -} -:root[data-theme="dark"]{ - --paper:#12161A; --surface:#191E24; --surface-2:#222831; - --ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E; - --rule:#2A3138; --rule-strong:#3B444D; - --brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426; - --clay:#D08A76; --clay-soft:#3A211B; - --l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD; - --chip-fg:#12161A; -} - -*{box-sizing:border-box} -body{ - margin:0; background:var(--paper); color:var(--ink); - font-family:var(--font-body); font-size:17px; line-height:1.62; - -webkit-font-smoothing:antialiased; -} -.wrap{max-width:1180px;margin:0 auto;padding:0 24px 96px} -.layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:56px;align-items:start} -@media (max-width:960px){.layout{grid-template-columns:1fr;gap:0}.rail{display:none}} - -/* ---------- rail ---------- */ -.rail{position:sticky;top:28px;padding-top:8px;font-family:var(--font-display);font-size:12px;line-height:1.5} -.rail ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px} -.rail a{color:var(--ink-3);text-decoration:none;display:flex;gap:9px} -.rail a:hover,.rail a:focus-visible{color:var(--brass)} -.rail .n{font-family:var(--font-mono);font-size:10px;color:var(--rule-strong);min-width:16px;padding-top:1px} -.rail .grp{margin-top:14px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--rule-strong)} - -/* ---------- header ---------- */ -header{padding:64px 0 40px;border-bottom:2px solid var(--ink);margin-bottom:44px} -.eyebrow{font-family:var(--font-mono);font-size:11.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:14px;margin-bottom:22px} -.eyebrow .stat{color:var(--clay)} -h1{font-family:var(--font-display);font-weight:800;letter-spacing:-.035em;line-height:.94;font-size:clamp(46px,9vw,92px);margin:0 0 6px;text-wrap:balance} -.sub{font-family:var(--font-display);font-weight:500;font-size:clamp(16px,2.4vw,21px);letter-spacing:-.01em;color:var(--ink-2);margin:0 0 30px;max-width:34ch;line-height:1.3} -.metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:20px 28px;border-top:1px solid var(--rule);padding-top:20px} -.metagrid dt{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px} -.metagrid dd{margin:0;font-family:var(--font-display);font-size:13.5px;line-height:1.45;color:var(--ink)} - -/* ---------- typography ---------- */ -section{margin-bottom:60px;scroll-margin-top:24px} -h2{font-family:var(--font-display);font-weight:750;letter-spacing:-.022em;font-size:clamp(24px,3.4vw,31px);line-height:1.12;margin:0 0 18px;text-wrap:balance;display:flex;gap:14px;align-items:baseline} -h2 .sn{font-family:var(--font-mono);font-size:12px;font-weight:400;color:var(--brass);letter-spacing:.06em;flex:none;padding-top:2px} -h3{font-family:var(--font-display);font-weight:700;font-size:16px;letter-spacing:-.008em;margin:34px 0 10px;color:var(--ink)} -p{margin:0 0 15px;max-width:var(--measure)} -ul,ol{max-width:var(--measure);margin:0 0 15px;padding-left:20px} -li{margin-bottom:7px} -strong{font-weight:600} -em{font-style:italic} -code{font-family:var(--font-mono);font-size:.855em;background:var(--surface-2);padding:1px 5px;border-radius:2px} -a{color:var(--brass)} -.lede{font-size:19px;line-height:1.55;color:var(--ink-2);max-width:60ch} - -/* ---------- devices ---------- */ -.callout{border-left:3px solid var(--brass);background:var(--brass-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)} -.callout p:last-child{margin-bottom:0} -.callout .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px} -.rule-quote{border-top:2px solid var(--ink);border-bottom:2px solid var(--ink);padding:26px 0;margin:28px 0;max-width:var(--measure)} -.rule-quote p{font-family:var(--font-display);font-weight:600;font-size:19px;line-height:1.38;letter-spacing:-.014em;margin:0;text-wrap:balance} -.hard{border-left:3px solid var(--clay);background:var(--clay-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)} -.hard .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--clay);display:block;margin-bottom:8px} -.hard p:last-child{margin-bottom:0} -.dec{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.08em;color:var(--brass);text-transform:uppercase} -.vec{font-family:var(--font-mono);font-size:.9em;font-weight:600;background:var(--surface-2);padding:2px 7px;border-radius:2px;white-space:nowrap;letter-spacing:.04em} - -/* ---------- tables ---------- */ -.scroll{overflow-x:auto;margin:0 0 24px;-webkit-overflow-scrolling:touch} -table{border-collapse:collapse;width:100%;min-width:520px;font-family:var(--font-display);font-size:13.5px;line-height:1.45} -th{text-align:left;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);font-weight:400;padding:0 16px 8px 0;border-bottom:1px solid var(--rule-strong);vertical-align:bottom} -td{padding:11px 16px 11px 0;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink-2)} -td:first-child{color:var(--ink);font-weight:600} -tbody tr:last-child td{border-bottom:none} -.lvl{font-family:var(--font-mono);font-weight:600;font-size:12px;letter-spacing:.04em;color:var(--ink)} - -/* ---------- ladders ---------- */ -.breakout{margin:34px 0 40px} -.bhead{display:flex;justify-content:space-between;align-items:baseline;gap:20px;border-bottom:1px solid var(--rule-strong);padding-bottom:9px;margin-bottom:22px;flex-wrap:wrap} -.bhead h3{margin:0;font-size:13px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--font-mono);font-weight:400;color:var(--ink-3)} -.bhead .note{font-family:var(--font-display);font-size:12.5px;color:var(--ink-3)} -.ladders{display:grid;gap:26px} -.ladder{display:grid;grid-template-columns:126px minmax(0,1fr);gap:18px;align-items:start} -@media (max-width:700px){.ladder{grid-template-columns:1fr;gap:10px}} -.ladder .pname{font-family:var(--font-display);font-weight:700;font-size:14px;letter-spacing:-.01em;padding-top:2px} -.ladder .pname span{display:block;font-family:var(--font-mono);font-size:10px;font-weight:400;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-top:3px} -.rungs{display:grid;gap:3px;grid-template-columns:repeat(5,minmax(0,1fr))} -@media (max-width:700px){.rungs{grid-template-columns:repeat(2,minmax(0,1fr))}} -.rung{padding:9px 10px 11px;background:var(--surface);border-top:4px solid var(--l0);min-width:0} -.rung.r1{border-top-color:var(--l1)} .rung.r2{border-top-color:var(--l2)} -.rung.r3{border-top-color:var(--l3)} .rung.r4{border-top-color:var(--l4)} -.rung .code{font-family:var(--font-mono);font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ink);display:block;margin-bottom:4px} -.rung .txt{font-family:var(--font-display);font-size:11.5px;line-height:1.34;color:var(--ink-2);display:block} -.rung.na{opacity:.42} - -/* ---------- matrix ---------- */ -.matrix-shell{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:stretch;margin-bottom:14px} -.ylab{writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);text-align:center;padding-bottom:22px} -.mgrid{display:grid;grid-template-columns:34px repeat(5,minmax(0,1fr));gap:3px} -.mcell{background:var(--surface);min-height:60px;padding:6px;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;min-width:0} -.mcell.tint1{background:color-mix(in srgb,var(--l1) 26%,var(--surface))} -.mcell.tint2{background:color-mix(in srgb,var(--l2) 26%,var(--surface))} -.mcell.tint3{background:color-mix(in srgb,var(--l3) 24%,var(--surface))} -.mcell.tint4{background:color-mix(in srgb,var(--l4) 22%,var(--surface))} -.mcell.void{background:repeating-linear-gradient(135deg,transparent,transparent 5px,var(--rule) 5px,var(--rule) 6px);opacity:.55} -.rlab,.clab{font-family:var(--font-mono);font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--ink-3);display:flex;align-items:center;justify-content:center} -.rlab{min-height:60px} -.clab{padding-top:7px;min-height:22px} -.pin{font-family:var(--font-mono);font-size:9.5px;font-weight:600;letter-spacing:.02em;background:var(--ink);color:var(--paper);padding:2px 5px;border-radius:2px;line-height:1.3;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.pin.ghost{background:transparent;color:var(--ink-2);border:1px dashed var(--rule-strong)} -.mnote{display:flex;gap:22px;flex-wrap:wrap;font-family:var(--font-display);font-size:12px;color:var(--ink-3);padding-top:6px} -.mnote .k{display:flex;align-items:center;gap:7px} -.sw{width:13px;height:13px;flex:none;background:var(--ink)} -.sw.g{background:transparent;border:1px dashed var(--rule-strong)} -.sw.v{background:repeating-linear-gradient(135deg,transparent,transparent 4px,var(--rule) 4px,var(--rule) 5px);border:1px solid var(--rule)} -@media (max-width:640px){ - .mgrid{grid-template-columns:28px repeat(5,minmax(0,1fr))} - .mcell{min-height:52px;padding:4px} - .pin{font-size:8px;padding:1px 3px} - .rlab{min-height:52px} -} - -/* ---------- methodology ---------- */ -.verbs{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule)} -.verb{background:var(--surface);padding:18px 18px 20px} -.verb h4{font-family:var(--font-display);font-weight:750;font-size:15px;margin:0 0 7px;letter-spacing:-.01em} -.verb p{font-family:var(--font-display);font-size:12.5px;line-height:1.46;color:var(--ink-2);margin:0;max-width:none} -.verb .step{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;color:var(--brass);display:block;margin-bottom:9px} - -/* ---------- questions ---------- */ -.qs{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--rule-strong)} -.q{display:grid;grid-template-columns:34px minmax(0,1fr) 170px;gap:18px;padding:16px 0;border-bottom:1px solid var(--rule);align-items:start} -@media (max-width:760px){.q{grid-template-columns:28px minmax(0,1fr);gap:12px}.q .owner{grid-column:2}} -.q .qn{font-family:var(--font-mono);font-size:11px;color:var(--brass);padding-top:3px} -.q .qt{font-family:var(--font-display);font-size:14px;line-height:1.48;color:var(--ink-2)} -.q .qt b{color:var(--ink);font-weight:700;display:block;margin-bottom:2px;font-size:14.5px} -.owner{font-family:var(--font-mono);font-size:10px;letter-spacing:.05em;color:var(--ink-3);padding-top:4px} -.owner .tag{display:inline-block;border:1px solid var(--rule-strong);padding:2px 7px;border-radius:2px} -.owner .tag.need{border-color:var(--clay);color:var(--clay)} - -/* ---------- misc ---------- */ -.numbers{font-family:var(--font-mono);font-size:12.5px;line-height:1.85;background:var(--surface);border-left:3px solid var(--l3);padding:16px 20px;margin:0 0 22px;overflow-x:auto;max-width:var(--measure)} -.numbers .v{color:var(--ink);font-weight:600} -.numbers .k{color:var(--ink-3)} -pre{font-family:var(--font-mono);font-size:12.5px;line-height:1.68;background:var(--surface);border-left:3px solid var(--rule-strong);padding:16px 20px;overflow-x:auto;margin:0 0 22px;max-width:var(--measure);color:var(--ink-2)} -.alt{border-bottom:1px solid var(--rule);padding:14px 0;max-width:var(--measure)} -.alt:last-of-type{border-bottom:none} -.alt b{font-family:var(--font-display);font-size:14px;display:block;margin-bottom:3px} -.alt p{font-size:14.5px;margin:0;color:var(--ink-2)} -.alt .verdict{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--clay)} -footer{border-top:2px solid var(--ink);margin-top:20px;padding-top:22px;font-family:var(--font-mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap} -.tm td,.tm th{text-align:center} -.tm td:first-child,.tm th:first-child{text-align:left} -.yes{color:var(--l4);font-weight:700} -.no{color:var(--clay);font-weight:700} -.kind{font-family:var(--font-mono);font-size:9px;letter-spacing:.09em;text-transform:uppercase;padding:2px 6px;border-radius:2px;white-space:nowrap;border:1px solid var(--rule-strong);color:var(--ink-3)} -.kind.adv{border-color:var(--clay);color:var(--clay)} -.routes{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule);margin:0 0 22px} -.route{background:var(--surface);padding:16px 18px} -.route h4{font-family:var(--font-display);font-weight:750;font-size:14px;margin:0 0 6px} -.route p{font-family:var(--font-display);font-size:12.5px;line-height:1.45;color:var(--ink-2);margin:0;max-width:none} -.route .tag{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px} -a:focus-visible,.rail a:focus-visible{outline:2px solid var(--brass);outline-offset:3px} -@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/tools/render-artifact.py b/tools/render-artifact.py deleted file mode 100644 index ecf2d06..0000000 --- a/tools/render-artifact.py +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env python3 -"""Render a canon markdown document into a styled, self-contained artifact page. - -Single source of truth: the markdown. The page is generated, never hand-edited, -so the two cannot diverge. - -Design devices are recognised from conventions already present in the markdown -rather than from extra markup, so the source stays a readable document: - - * a table whose first column is `**X0**`/`**X1**`... renders as a level ladder - * a table whose first header cell is `Threat` renders as a threat matrix - * a table with a `Kind` column renders with mechanical/adversarial chips - * a table whose first header cell is `E \\ P` renders as the E x P matrix - * a blockquote renders as a pull quote - * `**Decision N.N...**` at the start of a paragraph renders as a decision - * `## N. Title` headings build the section rail - -Stdlib only, per the estate's structure-not-tooling stance: a publishing step -that needs its own toolchain is a publishing step that stops being run. - -Usage: - python3 tools/render-artifact.py --output \\ - [--title "Name"] [--subtitle "..."] -""" - -from __future__ import annotations - -import argparse -import html -import pathlib -import re -import sys - -STYLE = pathlib.Path(__file__).parent / "artifact-style.css" - -INLINE_CODE = re.compile(r"`([^`]+)`") -BOLD = re.compile(r"\*\*([^*]+)\*\*") -EM = re.compile(r"(? str: - """Escape, then apply inline markdown. Order matters: code first.""" - slots: list[str] = [] - - def stash(rendered: str) -> str: - slots.append(rendered) - return f"\x00{len(slots) - 1}\x00" - - text = INLINE_CODE.sub(lambda m: stash(f"{html.escape(m.group(1))}"), text) - text = html.escape(text, quote=False) - text = LINK.sub( - lambda m: f'{m.group(1)}', text - ) - text = BOLD.sub(r"\1", text) - text = EM.sub(r"\1", text) - for i, rendered in enumerate(slots): - text = text.replace(f"\x00{i}\x00", rendered) - return text - - -def split_frontmatter(source: str) -> tuple[dict, str]: - if not source.startswith("---\n"): - return {}, source - end = source.index("\n---\n", 4) - meta = {} - for line in source[4:end].splitlines(): - if ":" in line and not line.startswith((" ", "-")): - key, _, value = line.partition(":") - meta[key.strip()] = value.strip().strip('"') - return meta, source[end + 5 :] - - -def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]: - rows, i = [], start - while i < len(lines) and lines[i].lstrip().startswith("|"): - cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] - if not all(set(c) <= set("-: ") for c in cells): - rows.append(cells) - i += 1 - return rows, i - - -# --- table renderers ------------------------------------------------------- - - -def render_ladder(rows: list[list[str]]) -> str: - """A level table becomes a stepped scale. Colour depth encodes strength.""" - body = rows[1:] - plane = LEVEL_CELL.match(body[0][0]).group(1) - names = { - "I": "Identity", "A": "Authorization", "E": "Enforcement", - "P": "Placement", "R": "Retention", - } - rungs = [] - for cells in body: - match = LEVEL_CELL.match(cells[0]) - if not match: - continue - n = int(match.group(2)) - text = cells[1] if len(cells) > 1 else "" - rungs.append( - f'
    {plane}{n}' - f'{inline(text)}
    ' - ) - while len(rungs) < 5: - rungs.append('
    —' - f'Ladder ends at {plane}{len(rungs) - 1}.
    ') - return ( - '
    ' - f'{names.get(plane, plane)}plane {plane}
    ' - f'
    {"".join(rungs)}
    ' - ) - - -def render_threat(rows: list[list[str]]) -> str: - head = "".join(f"{inline(c)}" for c in rows[0]) - body = [] - for cells in rows[1:]: - tds = [f"{inline(cells[0])}"] - for c in cells[1:]: - cls = "yes" if "✓" in c else "no" if "✗" in c else "" - tds.append(f'{inline(c)}') - body.append(f"{''.join(tds)}") - return (f'
    {head}' - f'{"".join(body)}
    ') - - -def render_matrix(rows: list[list[str]]) -> str: - """`E \\ P` table becomes the two-axis grid. Cells hold pins or markers.""" - cols = rows[0][1:] - cells = ['
    '] - for cells_row in rows[1:]: - e = cells_row[0].strip("*") - level = int(e[1]) if len(e) > 1 and e[1].isdigit() else 0 - cells.append(f'
    {html.escape(e)}
    ') - for value in cells_row[1:]: - v = value.strip() - if v == "—": - cells.append('
    ') - continue - tint = f" tint{level}" if level else "" - pins = "" - for entry in (p.strip() for p in v.split("
    ") if p.strip()): - ghost = " ghost" if entry.startswith("(") else "" - pins += f'{inline(entry.strip("()"))}' - cells.append(f'
    {pins}
    ') - cells.append('
    ') - cells.extend(f'
    {html.escape(c)}
    ' for c in cols) - cells.append("
    ") - return ( - '
    Enforcement →
    ' - + "".join(cells) - + "
    " - '
    ' - 'Where a service sits today' - 'Target or default' - 'Unreachable at this placement' - "
    " - ) - - -def render_table(rows: list[list[str]]) -> str: - if not rows: - return "" - header = [c.strip() for c in rows[0]] - first = header[0].lower() - if first.replace(" ", "") in {"e\\p", "e\\p"}: - return render_matrix(rows) - if first == "threat": - return render_threat(rows) - kind_col = header.index("Kind") if "Kind" in header else None - # A level table is a ladder. The evidence table also leads with `Level` but - # carries a `Kind` column, and is a table of artifacts, not of rungs. - if first == "level" and kind_col is None and len(rows) > 1 and LEVEL_CELL.match(rows[1][0]): - return render_ladder(rows) - head = "".join(f"{inline(c)}" for c in header) - body = [] - for cells in rows[1:]: - tds = [] - for i, c in enumerate(cells): - if i == kind_col: - adv = "adv" if "adversarial" in c.lower() else "" - label = re.sub(r"[*_]", "", c).strip() - tds.append(f'{html.escape(label)}') - else: - tds.append(f"{inline(c)}") - body.append(f"{''.join(tds)}") - return (f'
    {head}' - f'{"".join(body)}
    ') - - -# --- document --------------------------------------------------------------- - - -def render_body(markdown: str) -> tuple[str, list[tuple[str, str, str]]]: - lines = markdown.splitlines() - out: list[str] = [] - rail: list[tuple[str, str, str]] = [] - open_section = False - ladders_open = False - i = 0 - - def close_ladders() -> None: - nonlocal ladders_open - if ladders_open: - out.append("") - ladders_open = False - - while i < len(lines): - line = lines[i] - stripped = line.strip() - - if not stripped: - i += 1 - continue - - heading = HEADING.match(stripped) - if heading: - level, text = len(heading.group(1)), heading.group(2) - if level == 1: - i += 1 - continue - if level == 2: - close_ladders() - if open_section: - out.append("
    ") - match = SECTION_NO.match(text) - if match: - num, title = match.group(1), match.group(2) - anchor = f"s{num}" - rail.append((anchor, num, title)) - out.append( - f'

    ' - f'{int(num):02d}{inline(title)}

    ' - ) - else: - anchor = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") - rail.append((anchor, "·", text)) - out.append(f'

    {inline(text)}

    ') - open_section = True - else: - close_ladders() - out.append(f"

    {inline(text)}

    ") - i += 1 - continue - - if stripped.startswith("|"): - rows, i = parse_table(lines, i) - rendered = render_table(rows) - if 'class="ladder"' in rendered: - if not ladders_open: - out.append('
    ') - ladders_open = True - out.append(rendered) - else: - close_ladders() - out.append(rendered) - continue - - close_ladders() - - if stripped.startswith("```"): - block = [] - i += 1 - while i < len(lines) and not lines[i].strip().startswith("```"): - block.append(lines[i]) - i += 1 - out.append(f"
    {html.escape(chr(10).join(block))}
    ") - i += 1 - continue - - if stripped.startswith(">"): - quote = [] - while i < len(lines) and lines[i].strip().startswith(">"): - quote.append(lines[i].strip().lstrip(">").strip()) - i += 1 - out.append(f'

    {inline(" ".join(quote))}

    ') - continue - - if re.match(r"^[-*]\s+", stripped) or re.match(r"^\d+\.\s+", stripped): - ordered = bool(re.match(r"^\d+\.\s+", stripped)) - items = [] - while i < len(lines): - s = lines[i].strip() - if re.match(r"^[-*]\s+", s) or re.match(r"^\d+\.\s+", s): - items.append(re.sub(r"^([-*]|\d+\.)\s+", "", s)) - elif s and lines[i].startswith((" ", "\t")) and items: - items[-1] += " " + s - else: - break - i += 1 - tag = "ol" if ordered else "ul" - body = "".join(f"
  • {inline(t)}
  • " for t in items) - out.append(f"<{tag}>{body}") - continue - - if set(stripped) <= set("-") and len(stripped) >= 3: - i += 1 - continue - - para = [stripped] - i += 1 - while i < len(lines) and lines[i].strip() and not re.match( - r"^(\||>|```|#{1,4}\s|[-*]\s|\d+\.\s|---)", lines[i].strip() - ): - para.append(lines[i].strip()) - i += 1 - text = " ".join(para) - decision = DECISION.match(text) - if decision: - out.append( - f'

    {inline(decision.group(1))}' - f"{inline(decision.group(2))}

    " - ) - else: - out.append(f"

    {inline(text)}

    ") - - close_ladders() - if open_section: - out.append("
    ") - return "\n".join(out), rail - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("source", type=pathlib.Path) - ap.add_argument("--output", required=True, type=pathlib.Path) - ap.add_argument("--title", default=None) - ap.add_argument("--subtitle", default="") - args = ap.parse_args() - - meta, markdown = split_frontmatter(args.source.read_text()) - body, rail = render_body(markdown) - - title = args.title or meta.get("title", args.source.stem) - display = title.split(":")[0].strip() - eyebrow = " ".join( - f"{html.escape(v)}" - for k, v in ( - ("id", meta.get("id", "")), - ("status", f"{meta.get('status', '')} · {meta.get('revision', '')}".strip(" ·")), - ("date", meta.get("date", "")), - ) - if v - ) - rail_html = "".join( - f'
  • {n}{html.escape(t)}
  • ' - for a, n, t in rail - ) - - page = ( - f"{html.escape(display)}\n" - f"\n" - '
    ' - f'
    {eyebrow}generated from canon — do not edit
    ' - f"

    {html.escape(display)}

    " - + (f'

    {html.escape(args.subtitle)}

    ' if args.subtitle else "") - + '
    ' - f'' - f"
    {body}" - f'" - "
    \n" - ) - args.output.write_text(page) - print(f"{args.output}: {len(rail)} sections, {len(page)} bytes") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())