Publish tenancy posture draft-8 contract
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

This commit is contained in:
tegwick 2026-08-18 12:15:41 +02:00
parent 3efd5866ff
commit cced59d3aa
4 changed files with 630 additions and 112 deletions

View file

@ -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
}
}
}

View file

@ -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 <path>...`; 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 |
| **P1P4** | Provisioning declaration plus the platform's isolation probes | Mechanical |
| **P1P2 (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 P1P2 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 P1P4 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 ~520 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 §§38 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.

View file

@ -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()

View file

@ -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 '<root>'}: {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())