From cced59d3aa1dc0aa08fc128fc8c76699f59dcd90 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 12:15:41 +0200 Subject: [PATCH] Publish tenancy posture draft-8 contract --- .../schemas/tenancy-posture_v0.1.schema.json | 190 +++++++++ canon/standards/tenancy-posture_v0.1.md | 367 ++++++++++++------ tools/tenancy-posture/test_validate.py | 73 ++++ tools/tenancy-posture/validate.py | 112 ++++++ 4 files changed, 630 insertions(+), 112 deletions(-) create mode 100644 canon/schemas/tenancy-posture_v0.1.schema.json create mode 100644 tools/tenancy-posture/test_validate.py create mode 100644 tools/tenancy-posture/validate.py diff --git a/canon/schemas/tenancy-posture_v0.1.schema.json b/canon/schemas/tenancy-posture_v0.1.schema.json new file mode 100644 index 0000000..39714fd --- /dev/null +++ b/canon/schemas/tenancy-posture_v0.1.schema.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://policy.coulomb.social/schemas/tenancy-posture/v0.1", + "title": "NetKingdom tenancy posture declaration v0.1", + "type": "object", + "required": ["schema_version", "framework"], + "properties": { + "schema_version": {"const": "0.1"}, + "framework": {"const": "netkingdom-tenancy-posture"}, + "service": {"$ref": "#/$defs/serviceName"}, + "role": {"type": "string", "minLength": 1}, + "tenancy": {"$ref": "#/$defs/tenancy"}, + "provider": {"$ref": "#/$defs/provider"}, + "evidence": {"$ref": "#/$defs/evidence"}, + "notes": {"$ref": "#/$defs/stringList"}, + "services": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/service"} + } + }, + "oneOf": [ + { + "required": ["service", "role", "tenancy"], + "not": {"required": ["services"]} + }, + { + "required": ["services"], + "not": {"anyOf": [ + {"required": ["service"]}, + {"required": ["role"]}, + {"required": ["tenancy"]}, + {"required": ["provider"]}, + {"required": ["evidence"]} + ]} + } + ], + "additionalProperties": false, + "$defs": { + "serviceName": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "axisName": { + "enum": ["I", "A", "E", "P", "R", "V"] + }, + "level": { + "oneOf": [ + {"type": "integer", "minimum": 0, "maximum": 4}, + {"const": "n/a"} + ] + }, + "axisVector": { + "type": "object", + "required": ["I", "A", "E", "P", "R", "V"], + "properties": { + "I": {"oneOf": [{"type": "integer", "minimum": 0, "maximum": 3}, {"const": "n/a"}]}, + "A": {"$ref": "#/$defs/level"}, + "E": {"$ref": "#/$defs/level"}, + "P": {"$ref": "#/$defs/level"}, + "R": {"$ref": "#/$defs/level"}, + "V": {"$ref": "#/$defs/level"} + }, + "additionalProperties": false + }, + "partialAxisVector": { + "type": "object", + "minProperties": 1, + "properties": { + "I": {"oneOf": [{"type": "integer", "minimum": 0, "maximum": 3}, {"const": "n/a"}]}, + "A": {"$ref": "#/$defs/level"}, + "E": {"$ref": "#/$defs/level"}, + "P": {"$ref": "#/$defs/level"}, + "R": {"$ref": "#/$defs/level"}, + "V": {"$ref": "#/$defs/level"} + }, + "additionalProperties": false + }, + "stringMap": { + "type": "object", + "additionalProperties": {"type": "string", "minLength": 1} + }, + "stringList": { + "type": "array", + "items": {"type": "string", "minLength": 1} + }, + "paths": { + "type": "object", + "propertyNames": {"$ref": "#/$defs/axisName"}, + "additionalProperties": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"$ref": "#/$defs/level"} + } + }, + "placementException": { + "type": "object", + "required": ["tenants", "P", "reason"], + "properties": { + "tenants": {"$ref": "#/$defs/stringList"}, + "P": {"type": "integer", "minimum": 0, "maximum": 4}, + "reason": {"type": "string", "minLength": 1}, + "tier_ref": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "tenancy": { + "type": "object", + "required": ["current", "target", "reviewed", "review_due", "service_class"], + "properties": { + "current": {"$ref": "#/$defs/axisVector"}, + "implemented": {"$ref": "#/$defs/partialAxisVector"}, + "target": {"$ref": "#/$defs/axisVector"}, + "reviewed": {"type": "string", "format": "date"}, + "review_due": {"type": "string", "format": "date"}, + "service_class": {"enum": ["latency-critical", "interactive", "batch"]}, + "permanent": { + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/axisName"} + }, + "reason": {"$ref": "#/$defs/stringMap"}, + "gap": {"$ref": "#/$defs/stringMap"}, + "paths": {"$ref": "#/$defs/paths"}, + "placement_exceptions": { + "type": "array", + "items": {"$ref": "#/$defs/placementException"} + }, + "secondary_stores": {"$ref": "#/$defs/stringList"} + }, + "additionalProperties": false + }, + "providerAxis": { + "type": "object", + "required": ["available", "maximum"], + "properties": { + "available": {"$ref": "#/$defs/level"}, + "maximum": {"$ref": "#/$defs/level"}, + "conditions": {"$ref": "#/$defs/stringList"}, + "evidence": {"$ref": "#/$defs/stringList"}, + "reason": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "provider": { + "type": "object", + "required": ["axes"], + "properties": { + "capability": {"type": "string", "minLength": 1}, + "profile": {"type": "string", "minLength": 1}, + "axes": { + "type": "object", + "minProperties": 1, + "properties": { + "I": {"$ref": "#/$defs/providerAxis"}, + "A": {"$ref": "#/$defs/providerAxis"}, + "E": {"$ref": "#/$defs/providerAxis"}, + "P": {"$ref": "#/$defs/providerAxis"}, + "R": {"$ref": "#/$defs/providerAxis"}, + "V": {"$ref": "#/$defs/providerAxis"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "evidence": { + "type": "object", + "additionalProperties": { + "oneOf": [ + {"type": "string", "minLength": 1}, + {"$ref": "#/$defs/stringList"} + ] + } + }, + "service": { + "type": "object", + "required": ["service", "role", "tenancy"], + "properties": { + "service": {"$ref": "#/$defs/serviceName"}, + "role": {"type": "string", "minLength": 1}, + "tenancy": {"$ref": "#/$defs/tenancy"}, + "provider": {"$ref": "#/$defs/provider"}, + "evidence": {"$ref": "#/$defs/evidence"}, + "notes": {"$ref": "#/$defs/stringList"} + }, + "additionalProperties": false + } + } +} diff --git a/canon/standards/tenancy-posture_v0.1.md b/canon/standards/tenancy-posture_v0.1.md index 1e96d0f..30d2e81 100644 --- a/canon/standards/tenancy-posture_v0.1.md +++ b/canon/standards/tenancy-posture_v0.1.md @@ -8,7 +8,11 @@ version: "0.1" created: "2026-08-17" updated: "2026-08-17" scope: multi-tenancy-security-framework -revision: "draft-7" +revision: "draft-8" +owner: net-kingdom +last_reviewed: "2026-08-17" +review_interval: 6m +declaration_schema: canon/schemas/tenancy-posture_v0.1.schema.json adr: - docs/adr/ADR-0006-recursive-multi-tenant-identity-authorization.md - docs/adr/ADR-0013-tenant-onboarding-grouping-taxonomy.md @@ -20,11 +24,12 @@ related: - docs/platform-identity-security-architecture.md --- -# NetKingdom Tenancy Posture v0.1 — Five Axes, Graduated Levels, Declared Conformance +# NetKingdom Tenancy Posture v0.1 — Six Axes, Graduated Levels, Declared Conformance ## Status -**Proposed, draft-7.** Relocated from `the-custodian/canon/architecture` on +**Proposed, draft-8; ratification-ready.** Relocated from +`the-custodian/canon/architecture` on 2026-08-17: multi-tenancy is part of the IT-security framework NetKingdom provides, so this framework belongs in NetKingdom canon beside the IAM Profile and the tenant-engine boundary contract, not in the work-factory canon. @@ -47,20 +52,23 @@ and the tenant-engine boundary contract, not in the work-factory canon. Eleven further changes, two of them corrections to statements this document made as fact about other repos. **Every posture I guessed was too generous, on every repo that has now self-reported.** +- **draft-8** applies `adaptive-pricing`'s review, the last of the six, and the + consistency review across all declarations. It adds the missing availability + axis, a canonical declaration schema, explicit authority for tier assurance, + retention/placement coupling, downgrade propagation, and honest sanctioned + customer language. It also corrects the distinction between an implemented + control and an evidenced current level. -**Reviewed by four of six. The score so far:** four repos found three live -defects in their own code by reading the ladders — `tenant-engine`'s unfiltered +**Reviewed by all six. The score:** six repos found three live defects in their +own code by reading the ladders — `tenant-engine`'s unfiltered event accessor, `audit-core`'s unfiltered read path, `flex-auth`'s unauthenticated `/v1/check` — and `railiance-platform` found `apps-pg` running with no backup configured at all while writing its §10.2 disclosure. The -document changed eleven times and no repo was told it was wrong. +framework changed to fit the repos; no repo was told to fabricate a posture. -Every correction so far was found by research or by relocation, 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. +Informed by five external research digests plus their index in +`the-custodian/research/2026-08-17-adr008-*`, which carry full citations for +the external claims made here. ## 0. Terminology: axes, not planes @@ -74,20 +82,20 @@ different: an independent dimension of concern. Two incompatible senses of one word inside one canon is exactly the concept-ownership collision the estate has been careful about elsewhere, and the newcomer yields. -This framework therefore describes five **axes**. They are orthogonal to +This framework therefore describes six **axes**. They are orthogonal to NetKingdom's planes, not a subdivision of them: - A **plane** is *where* something runs and what trust it carries — bootstrap, platform control, tenant. - An **axis** is *which property* of tenancy is being described — identity, - authorization, enforcement, placement, retention. + authorization, enforcement, placement, retention, availability. -A workload in the tenant plane has a position on all five axes. A platform +A workload in the tenant plane has a position on all six axes. A platform control plane service does too. The two vocabularies compose and neither replaces the other. The rename is also an improvement. A posture vector is literally a point in -five-dimensional space, and "axis" says that where "plane" did not. +six-dimensional space, and "axis" says that where "plane" did not. ## 1. Context @@ -113,20 +121,22 @@ This document is downstream of that architecture and must not restate it. It answers one question the architecture leaves open: given the model, **where is this particular service today, and how would anyone know?** -Four failures follow. +Four failures existed when drafting began. **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 +**Placement was 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. +raised this with `railiance-platform` on 2026-08-16. Draft-8 resolves the +authority split in §8.2. -**Two contradictory defaults are already ratified.** Business apps get +**Two contradictory defaults were 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. +service takes, and no definition separates the categories. Decision 4.4.1 now +supplies the default; §19.4 retains the missing classification rule. **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 @@ -143,9 +153,9 @@ 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 axes +## 3. Six orthogonal axes -"Is this multi-tenant?" is treated as one question. It is five, and they are +"Is this multi-tenant?" is treated as one question. It is six, and they are independent: | Axis | Question | Vocabulary owner | @@ -155,6 +165,7 @@ independent: | **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 | +| **Availability (V)** | What failure can the complete service path survive, and within what recovery objective? | The delivering service; substrate facts by its providers | Conflation produces errors today. `rapp-postgres`'s `PostgresConsumer` carries `tenantIsolation: consumer-service-boundary` — an **E**-axis fact in a @@ -178,12 +189,18 @@ stated where they apply, not used to argue the axes are one: 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.** +- `V` composes as the **minimum across the critical request path**, not the + maximum of its components. A replicated application on a single-instance + database is not V2. A tested degraded mode may remove a dependency from that + path, but the bypass itself is part of the V evidence. **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. +datastore**. The V ladder describes the service's complete critical request +path, including providers it synchronously depends on. Caches, search indices, +message queues and background jobs are named leak surfaces in the external +baselines and are assessed separately, not silently covered by a datastore +level. A declaration names material secondary stores and asynchronous paths as +exceptions rather than implying that one vector proves them safe. ## 4. Graduated levels @@ -457,26 +474,72 @@ reason with nothing to do with performance — which is exactly why it needs recording, since nobody looks for a retention argument when reviewing placement. +**Decision 4.5.4 — a retention promise binds both R and P.** A tier making a +retention claim records an R minimum and a maximum erasure horizon in days. It +also requires P2 or above **unless** its provider contract guarantees that the +shared-substrate horizon stays within that maximum and rejects or notifies +before a co-resident change would extend it. A bare `R2` minimum is +insufficient: at P1 another consumer can change the promise without changing +the tier or its holder. + 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. +### 4.6 Availability (V) + +New in draft-8. `adaptive-pricing` found that §11 required availability claims +to map to a minimum level while the framework supplied no availability +vocabulary. Placement is not a substitute: a dedicated cluster can still be a +single instance on a single node. + +| Level | State | +|---|---| +| **V0** | No availability or recovery position. Recovery is untested or depends on improvisation. | +| **V1** | Restart or recreate recovery in one failure domain is documented and exercised. Interruption is expected; this is recovery, not failover. | +| **V2** | Redundant instances provide automated service failover, with measured RTO/RPO; a shared failure domain or critical dependency may remain. | +| **V3** | The complete critical path survives loss of one declared failure domain, with measured RTO/RPO from an exercise. | +| **V4** | The complete critical path survives regional loss through tested multi-region failover, with measured RTO/RPO. | + +**Decision 4.6.1 — V is end-to-end.** A service declares the minimum across +the components and synchronous providers required to serve the operation. An +application with three replicas over a V1 database is V1. A status page or +replica count is not evidence of a higher level. + +**Decision 4.6.2 — availability claims name the operation.** A read-only +degraded mode and a mutation path may have different V levels. Decision 5.2 +applies: declare the paths and quote the minimum unless the customer-facing +claim explicitly and unambiguously names the narrower operation. + ## 5. The posture vector -A service states one level per axis, plus a target, a date, and any placement -exceptions: +A service states one level per axis, plus a target, review dates, evidence and +any exceptions. `current` is the highest **evidenced** level; a control present +in code but still awaiting the evidence required by §13 goes in `implemented`, +not in `current`: ```yaml +schema_version: "0.1" +framework: netkingdom-tenancy-posture +service: example-service +role: tenant-data-service tenancy: - current: { I: 2, A: 3, E: 2, P: 1, R: 1 } - target: { I: 2, A: 3, E: 3, P: 1, R: 2 } + current: { I: 2, A: 3, E: 2, P: 1, R: 1, V: 1 } + implemented: { E: 3 } + target: { I: 2, A: 3, E: 3, P: 1, R: 2, V: 2 } reviewed: "2026-08-17" + review_due: "2027-02-17" + service_class: interactive gap: - E: "Choke point exists and is tested; RLS not provisioned. Blocked on - rapp-postgres publishing the GUC contract. Target Q4." + E: "RLS is implemented; the §13 E3 probe is still absent." R: "Retention declared; erasure horizon not yet published to consumers." + V: "Automated failover is not implemented or exercised." +evidence: + A3: "docs/evidence/authorization-denial.md" + E2: "docs/evidence/cross-tenant-review.md" + P1: "rapp-postgres/docs/evidence/isolation-2026-08-10.md" ``` **Placement exceptions.** Draft-2 assigned one P level per service, which @@ -496,7 +559,7 @@ A service with exceptions must be able to say which tenants are on which substrate. That mapping is a first-class artifact, not archaeology. **Decision 5.5 — a provider declares what it makes reachable, not where it -sits.** The five ladders describe a *consumer* of infrastructure. They describe +sits.** The six ladders describe a *consumer* of infrastructure. They describe a *provider* of it badly, and `railiance-platform`'s review demonstrated how badly: `apps-pg` is `I0 A0 E0` because a database has no tenant concept, carries no tenant claim and applies no tenant predicate. Those zeros are @@ -509,26 +572,29 @@ machinery — pointed at a *consumer* boundary rather than a tenant one. A reade scanning a column of E values would rank it below a service doing per-query filtering in application code, inverting the real security position. -So a platform service additionally declares, per axis, **the maximum level it -makes reachable and what a consumer must do to reach it**. For `apps-pg`: `E4` -unreachable (shared credential per consumer, no per-tenant credential), `E3` -reachable once the GUC contract exists, `R2` blocked on a backup target. That is -the sentence a consumer actually needs, and no arrangement of the consumer -ladders produces it. +So a platform service additionally declares, per axis, **the level available +now, the maximum it can make reachable, and what a consumer must do to reach +it**. For `apps-pg`: E4 unreachable (shared credential per consumer, no +per-tenant credential), E3 conditional on the GUC contract, R2 blocked on a +backup target, V1 at most on the single-node rail. That is the sentence a +consumer actually needs, and no arrangement of the consumer ladders produces +it. A provider's own `P` is `n/a`, not a number. `apps-pg` *provides* `P1`; it is not *at* `P1`, and writing `P: 1` there would later read as an isolation claim. -Worked examples, self-reported where marked, and every guess so far has been -too generous: +Worked examples after applying the evidence rule and minimum-across-paths rule +consistently: | Service | Current | Notes | |---|---|---| -| `tenant-engine` | `I1 A2 E2 P— R0/R1` | **Self-reported on review**, correcting a more generous guess. I1: acting identity comes from the request body, not a verified token. A2: three read routes unauthorized. P—: still on SQLite, P1 is TEN-WP-0009's target. R: see §4.5 on the erasure/retention split. | -| `audit-core` | `I1 A2 E1 P1 R1→R2` | **Self-reported**, three axes below my guess. E1 because the read path applies no tenant filter at all — a defect found by reading the ladder. Bounded by deployment, not by code. | -| `flex-auth` | `I1 A0 E2 P n/a R n/a` | **Self-reported**, `enables A3` for consumers. A0: `/v1/check` authenticates no caller, so any workload with network reach can assert any subject and receive an authoritative allow. | -| `apps-pg` (provider) | `I0 A0 E0 P n/a R0` | **Self-reported.** Zeros are structural. `R0` here means no backup configured at all — found while writing the §10.2 disclosure. | -| A newly absorbed repo | `I1 A1 E1 P0 R0` | Conformant **if declared**, with a recorded path. | +| `tenant-engine` | `I1 A0 E1 P n/a R0 V0` | Acting identity is caller-supplied; unauthorised read paths set the A minimum; E2-shaped child-table controls are not evidenced; SQLite is outside P; no erasure or availability evidence. This corrects draft-7, which quoted A2/E2 despite its own minimum/evidence rules. | +| `audit-core` | `I1 A2 E1 P1 R2 V0` | E2 is implemented on both paths but awaits the adversarial artifact, so current remains E1. Its 30-day retention and erasure horizon are now declared and published. | +| `flex-auth` | `I1 A0 E1 P n/a R n/a V0` | Enables A3 for consumers. `/v1/check` authenticates no caller; E2 is implemented but not evidenced. | +| `platform-pg` (provider) | `I0 A0 E0 P n/a R2 V1` | Provides P1; backup/restore and single-node recovery are evidenced. Provides no tenant boundary by itself. | +| `apps-pg` (provider) | `I0 A0 E0 P n/a R0 V0` | Zeros are structural, except R0/V0 are live gaps: no backup and no recovery evidence. | +| `adaptive-pricing` observatory | `I0 A0 E0 P n/a R n/a V0` | Local, unauthenticated, single-user analysis surface; not a production service. | +| A newly absorbed repo | `I1 A1 E1 P0 R0 V0` | 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. @@ -550,19 +616,29 @@ Two services found this shape in themselves within a day of each other — enforce harder on write than read, so this is the common case, not the corner. **Decision 5.3 — `n/a` is a level, and it is conformant.** `P0` presupposes a -database and `R0` presupposes retained data. A service holding nothing at rest -— `flex-auth` runs with its registry and policy baked read-only into the image -and no decision log persisted — is neither. Without an admissible `n/a`, a +shared database and `R0` presupposes retained data. A service holding nothing +at rest — `flex-auth` runs with its registry and policy baked read-only into the +image and no decision log persisted — is neither. A datastore outside a +ladder's substrate vocabulary, such as `tenant-engine`'s current SQLite PVC, +also uses `n/a` rather than inventing a level. Without an admissible `n/a`, a missing rung **forces** the fabrication §6 prohibits, which is precisely what draft-1 was rejected for. `n/a` is declared with a stated reason. **Decision 5.4 — the vector lives at `tenancy.yaml` in the repo root.** Draft-6 said "in the repo" and not where or in what shape, which left §12's guard needing per-repo archaeology. `flex-auth` adopted `tenancy.yaml` -speculatively; adopted here as the convention. The schema carries `current`, -`target`, `reviewed`, `gap`, `placement_exceptions`, `service_class` (§8.3), -per-path detail (§5.2), and — for a service that is one — the provider -declaration (§5.5). +speculatively; adopted here as the convention. A repo representing one service +uses the single-service form above. A layer repo uses the schema's `services` +list in the same root file — one vector per service, never an average. The +normative schema is +`canon/schemas/tenancy-posture_v0.1.schema.json`; prose documents may explain a +declaration but do not replace it. The schema carries `current`, `implemented`, +`target`, `reviewed`, `review_due`, `gap`, `placement_exceptions`, +`service_class` (§8.3), per-path detail (§5.2), and provider reachability +(§5.5). From the `net-kingdom` repo, owners validate one or more declarations +with `uv run tools/tenancy-posture/validate.py ...`; the validator applies +the JSON Schema and the evidence, date, implemented/current and provider-range +rules that JSON Schema alone cannot express. ## 6. Conformance is accuracy, not altitude @@ -584,9 +660,18 @@ declaration (§5.5). a reason is a settled position, not a stalled trajectory, and §12's guard must not nag it as though it were one. +**Decision 6.1 — downgrades propagate.** Before a planned downgrade of a +current level or a provider's available level, the declaring repo MUST resolve +the tier definitions and consumers that reference it. A downgrade below a +recorded minimum blocks the change until the claim is changed, the workload is +moved, or the affected owner explicitly accepts the gap. An unplanned +regression is an incident and triggers the same notifications. Updating +`tenancy.yaml` without notifying dependants is declaration drift, not a +completed downgrade. + Without the axis 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. +verdict a repo passes or fails. With it, the same repo is +`I1 A1 E1 P0 R0 V0` with a path — a plan, not an indictment. ## 7. Portability across placement levels @@ -612,10 +697,20 @@ 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. +**Decision 8.2 — split authority, machine-reconciled.** `railiance-platform` +owns the placement rule; the package repo owns the substrate numbers and +enforcement; the consuming repo owns its workload requirements; +`adaptive-pricing` owns any tier minimum. `adaptive-pricing` declined a standing +co-signature and the framework accepts the replacement: typed tier minima are +joined to consumer and provider declarations at tier definition and whenever +one changes. A machine-checkable constraint must not depend on somebody +remembering to collect a signature. + +**Decision 8.2.1 — trigger monitoring has an owner.** The provider monitors +capacity ceilings and co-residency; the consumer monitors latency, compliance +and erasure requirements; `adaptive-pricing` monitors tier-definition changes. +The placement owner reconciles those signals. A trigger marked `unmonitored` is +an explicit gap and cannot support a customer assurance claim. ### 8.3 Service class — a placement input, never a priority @@ -700,8 +795,9 @@ 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.1** Plan tiers are expressed *internally* as typed assurance + requirements. A tier may require `E3 P2 R2 V2` and a maximum erasure horizon; + it need not print those labels 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. @@ -717,6 +813,26 @@ provisioned, by good practice rather than by rule; the rule now exists. 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. + - A claim that service survives loss of a zone requires **V3**; regional-loss + language requires **V4**. "High availability" without a named failure and + measured recovery objective is not an assurance claim this framework can + evidence. +- **11.5 — sanctioned honest language.** The strong prohibitions above must + not leave a commercial writer with only silence: + - E3 may be described as database-backed defence against an omitted tenant + filter; it must not be paraphrased as "another tenant cannot reach". + - P2 may be described as a dedicated service database cluster with an + independent capacity and restore boundary; it is not tenant-dedicated. + - R2 may state the declared retention and published erasure horizon. + - V1 may state exercised restart recovery in one failure domain and must say + that interruption and single-domain loss remain. +- **11.6 — authority and reconciliation.** The tier definition is authoritative + for the minimum and customer wording. `tenancy.yaml` is authoritative for + the delivering service's current level; provider declarations are + authoritative for what infrastructure makes available. None is derived by + copying another. Approval joins them and fails closed on a missing, stale or + insufficient declaration. A performance-differentiated tier requires P2 or + an enforceable resource governor; service class alone grants no priority. ## 12. Methodology — analyze, establish, improve, guard @@ -741,8 +857,10 @@ 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.1:** a current level is claimed only with its evidence artifact +present. This turns §6's accuracy rule from an honour system into a check. +`implemented` records a control observed in code or configuration whose required +artifact is still absent; it never satisfies a tier minimum. **Decision 13.1a — the floor needs no artifact, only a reason.** Found independently by `audit-core` and `flex-auth`: the table below defines @@ -752,9 +870,9 @@ conformant absorbed repo, `I1 A1 E1 P0 R0`, which could not satisfy it on any axis. A rule that forbids the declaration §6 exists to permit is a defect in the rule. -At or below the "no control" rung of an axis, a declaration requires a **stated -reason**, not an artifact. Evidence is what stops you overclaiming, and there is -nothing to overclaim at the bottom of a ladder. +At `I0/I1`, `A0/A1`, `E0`, `P0`, `R0/R1`, `V0`, or `n/a`, a declaration +requires a **stated reason**, not an artifact. Evidence is what stops you +overclaiming, and there is nothing to overclaim at those floors. **Decision 13.4 — an artifact must assert something achievable.** Draft-3's noisy-neighbour evidence required proof that a saturating consumer "does not @@ -784,19 +902,31 @@ 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)** | 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 against each one's declared service class** (§8.3); the aggregate headroom at time of measurement | **Adversarial**, load-generated, with a review date | +| **Shared P1–P2 capacity assurance** | 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 against each one's declared service class** (§8.3); 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** | +| **V1** | Critical dependencies enumerated; restart/recreate recovery exercised; interruption and measured recovery time recorded | Mechanical exercise | +| **V2** | One instance terminated while traffic continues or recovers automatically; measured RTO/RPO and remaining shared failure domains recorded | **Adversarial**, failure-injected | +| **V3** | Declared failure domain removed in an exercise; complete critical path and degraded modes observed against RTO/RPO | **Adversarial**, failure-injected | +| **V4** | Region made unavailable in an exercise; traffic and state recover in the alternate region against RTO/RPO | **Adversarial**, failure-injected | -**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 +The P1–P4 artifact proves the declared placement topology. The shared-capacity +artifact is additional: it is required before a P1/P2 service can claim that a +noisy-neighbour control binds, that the trigger is actively guarded, or that a +customer performance assurance survives co-residency. It is not required merely +to report the true topology as P1 or P2. No such capacity artifact exists in the +estate today, so §11 requires P2 or an enforceable governor for a +performance-differentiated tier. + +**Decision 13.3:** the tenant-boundary E2/E3 and noisy-neighbour artifacts do +not exist anywhere in the estate today. `rapp-postgres` runs 19 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. +our class of system. §19.3 records the owner. ## 14. Adoption stance — structure, not tooling @@ -887,12 +1017,15 @@ 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. +Sources: the five research digests in +`the-custodian/research/2026-08-17-adr008-*`, which carry full citations for +the claims in this section. ## 17. Scaling demands -Measured against the live `platform-pg` specification, not estimated. +Derived from the live `platform-pg` specification. Connection arithmetic is +exact; the per-backend memory estimate remains unmeasured and is explicitly a +gap in `rapp-postgres` ADR-0004. ``` instances: 1 (no HA; single-node rail) @@ -901,19 +1034,21 @@ 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. +**The hard connection bound is roughly six declarations; the enforceable +operational ceiling is four.** Seven declarations request 98 of 100 connections +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. ADR-0004 sets four because memory is expected to bind first and +fails by OOM-killing every co-resident rather than refusing one connection. 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. +number describes. Two workload consumers plus the isolation probe occupy three +of the four declared slots. The next workload request must trigger measurement +and the overflow decision before admission. **Memory likely binds first.** 100 backends against 1Gi is ~10MB per backend. Connection exhaustion errors clearly; memory pressure OOM-kills and degrades @@ -933,9 +1068,9 @@ retention extends everyone's horizon *and* everyone's storage draw against a **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. +**`platform-pg` is V1.** `instances: 1` on a single-node rail provides exercised +restart recovery and no failover. P1 describes its consumer placement and says +nothing about this availability fact; the new V axis carries it. ## 18. Consequences @@ -951,15 +1086,18 @@ minimum-level vocabulary alongside isolation. document. - A service selling an isolation tier must maintain a tenant→substrate mapping it does not have today. +- Availability becomes an end-to-end, evidenced property rather than an + inference from replica count or placement. - Nothing here changes a running system. -## 19. Open questions +## 19. Review resolutions and residual questions -1. **`tenantIsolation` field** — `rapp-postgres`: rename to name its axis 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. +1. **`tenantIsolation` field — resolved.** `rapp-postgres` retired it. A + consumer declaration asks for mechanisms; posture lives in the consumer's + `tenancy.yaml`. +2. **Placement ownership — resolved.** §8.2 records the split. The policy has + one owner; typed tier requirements replace the declined commercial + co-signature. 3. **E2, E3 and noisy-neighbour evidence** — **owned as of 2026-08-17** by `whitehat-security` (WHITEHAT-WP-0001), an independent adversarial evidence facility seeded for this purpose. `audit-core` and `tenant-engine` were @@ -988,31 +1126,33 @@ minimum-level vocabulary alongside isolation. did not work. §13's evidence artifacts should be read with that distinction, because a green run recorded as "E2 verified" would be exactly the overclaim §6 prohibits. -4. **Business app vs platform service** — Custodian canon: a classification +4. **Business app vs platform service — open.** 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 +5. **Tier → minimum level mapping — policy resolved, implementation open.** + `adaptive-pricing` owns typed minima and wording; `tenant-engine` owns plan + assignment by id. Current tiers make no assurance claims. +6. **The E3 mechanism — resolved.** `rapp-postgres` ADR-0003 publishes the GUC + contract with the `FORCE`/`BYPASSRLS`/`SECURITY INVOKER`/`EXPLAIN` + requirements. +7. **Identity-provider placement — open.** 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. +8. **Cell sizing — resolved for `platform-pg`.** `rapp-postgres` ADR-0004 sets + four consumers and names absent overflow target `platform-pg-2`; measurement + and provisioning remain live gaps. +9. **Retention floor and ceiling — resolved as policy.** Both exist; requests + outside them fail validation and the package repo owns the numbers. +10. **Engine neutrality — open.** 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 +11. **Erasure versus audit — framework resolved.** `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. + its own evidence. `audit-core` targets R2 and is explicitly not a fleet R4 + target. The legal basis for retaining audit facts remains a risk/legal + question outside this framework. 12. **Quality of service** — **resolved 2026-08-17.** Co-residents are equal; a declared *service class* informs placement but never grants priority. See §8.3. The question asked whether to add a QoS dimension; the answer is @@ -1027,15 +1167,18 @@ in here would overreach. ## 20. Ratification path -1. Reviewed by `tenant-engine`, `flex-auth`, `rapp-postgres`, - `railiance-platform` and `adaptive-pricing` against §19. +1. Reviewed by `tenant-engine`, `flex-auth`, `audit-core`, `rapp-postgres`, + `railiance-platform` and `adaptive-pricing` against §19. **Complete in + draft-8.** 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. + repo cannot express itself in these six ladders, the ladders are wrong and + this document changes, not the repo. **Complete in draft-8; all six root + declarations validate against the canonical schema.** 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. +4. On acceptance, `rapp-postgres` ADR-0001 through ADR-0004 move to `accepted` + and are annotated as the PostgreSQL implementation of the E, P, R and + shared-capacity rules. diff --git a/tools/tenancy-posture/test_validate.py b/tools/tenancy-posture/test_validate.py new file mode 100644 index 0000000..7c262ca --- /dev/null +++ b/tools/tenancy-posture/test_validate.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import importlib.util +import pathlib +import unittest + + +MODULE_PATH = pathlib.Path(__file__).with_name("validate.py") +SPEC = importlib.util.spec_from_file_location("tenancy_posture_validate", MODULE_PATH) +assert SPEC and SPEC.loader +VALIDATE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATE) + + +def declaration() -> dict: + reasons = {axis: "floor explained" for axis in VALIDATE.AXES} + return { + "schema_version": "0.1", + "framework": "netkingdom-tenancy-posture", + "service": "example", + "role": "test", + "tenancy": { + "current": {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0}, + "target": {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0}, + "reviewed": "2026-08-17", + "review_due": "2027-02-17", + "service_class": "interactive", + "reason": reasons, + }, + } + + +class SemanticValidationTests(unittest.TestCase): + def validate(self, document: dict) -> list[str]: + return VALIDATE.validate_semantics(document, pathlib.Path("tenancy.yaml")) + + def test_floor_vector_with_reasons_is_valid(self) -> None: + self.assertEqual([], self.validate(declaration())) + + def test_level_above_floor_requires_exact_evidence_key(self) -> None: + document = declaration() + document["tenancy"]["current"]["A"] = 2 + self.assertIn("current A2 has no evidence entry", self.validate(document)[0]) + document["evidence"] = {"A2": "docs/evidence/authorization.md"} + self.assertEqual([], self.validate(document)) + + def test_implemented_level_must_be_above_current(self) -> None: + document = declaration() + document["tenancy"]["implemented"] = {"E": 0} + self.assertIn("implemented E0 must be above current E0", self.validate(document)[0]) + + def test_provider_available_cannot_exceed_maximum(self) -> None: + document = declaration() + document["provider"] = {"axes": {"V": {"available": 2, "maximum": 1}}} + self.assertIn("provider V available 2 exceeds maximum 1", self.validate(document)[0]) + + def test_review_due_cannot_precede_review(self) -> None: + document = declaration() + document["tenancy"]["review_due"] = "2026-08-16" + self.assertIn("review_due precedes reviewed", self.validate(document)[0]) + + def test_service_names_are_unique(self) -> None: + entry = declaration() + document = { + "schema_version": "0.1", + "framework": "netkingdom-tenancy-posture", + "services": [entry, entry], + } + self.assertIn("service names must be unique", self.validate(document)[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tenancy-posture/validate.py b/tools/tenancy-posture/validate.py new file mode 100644 index 0000000..7cc486e --- /dev/null +++ b/tools/tenancy-posture/validate.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["jsonschema>=4.23,<5", "PyYAML>=6,<7"] +# /// +"""Validate tenancy.yaml files against the canonical syntax and semantics.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import pathlib +import sys +from typing import Any + +import jsonschema +import yaml + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCHEMA = ROOT / "canon/schemas/tenancy-posture_v0.1.schema.json" +AXES = ("I", "A", "E", "P", "R", "V") +FLOORS = {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0} + + +def services(document: dict[str, Any]) -> list[dict[str, Any]]: + if "services" in document: + return document["services"] + return [document] + + +def validate_semantics(document: dict[str, Any], path: pathlib.Path) -> list[str]: + errors: list[str] = [] + entries = services(document) + names = [entry["service"] for entry in entries] + if len(names) != len(set(names)): + errors.append("service names must be unique") + + for entry in entries: + name = entry["service"] + posture = entry["tenancy"] + current = posture["current"] + reason = posture.get("reason", {}) + gap = posture.get("gap", {}) + evidence = entry.get("evidence", {}) + + reviewed = dt.date.fromisoformat(posture["reviewed"]) + review_due = dt.date.fromisoformat(posture["review_due"]) + if review_due < reviewed: + errors.append(f"{name}: review_due precedes reviewed") + + for axis in AXES: + level = current[axis] + explained = bool(reason.get(axis) or gap.get(axis)) + if level == "n/a" or (isinstance(level, int) and level <= FLOORS[axis]): + if not explained: + errors.append(f"{name}: {axis}{level} needs reason or gap text") + continue + key = f"{axis}{level}" + if key not in evidence: + errors.append(f"{name}: current {key} has no evidence entry") + + implemented = posture.get("implemented", {}) + for axis, level in implemented.items(): + current_level = current[axis] + if current_level != "n/a" and level != "n/a" and level <= current_level: + errors.append( + f"{name}: implemented {axis}{level} must be above current {axis}{current_level}" + ) + + provider = entry.get("provider", {}) + for axis, reach in provider.get("axes", {}).items(): + available = reach["available"] + maximum = reach["maximum"] + if available != "n/a" and maximum != "n/a" and available > maximum: + errors.append( + f"{name}: provider {axis} available {available} exceeds maximum {maximum}" + ) + + return [f"{path}: {error}" for error in errors] + + +def validate(path: pathlib.Path, schema: dict[str, Any]) -> list[str]: + document = yaml.safe_load(path.read_text(encoding="utf-8")) + validator = jsonschema.Draft202012Validator( + schema, format_checker=jsonschema.FormatChecker() + ) + errors = [ + f"{path}: {'/'.join(str(part) for part in error.absolute_path) or ''}: {error.message}" + for error in sorted(validator.iter_errors(document), key=lambda item: list(item.path)) + ] + if not errors: + errors.extend(validate_semantics(document, path)) + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("declarations", nargs="+", type=pathlib.Path) + args = parser.parse_args() + schema = json.loads(SCHEMA.read_text(encoding="utf-8")) + errors = [error for path in args.declarations for error in validate(path, schema)] + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + for path in args.declarations: + print(f"{path}: valid") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())