Publish tenancy posture draft-8 contract
This commit is contained in:
parent
3efd5866ff
commit
cced59d3aa
4 changed files with 630 additions and 112 deletions
190
canon/schemas/tenancy-posture_v0.1.schema.json
Normal file
190
canon/schemas/tenancy-posture_v0.1.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
||||
| **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.
|
||||
|
|
|
|||
73
tools/tenancy-posture/test_validate.py
Normal file
73
tools/tenancy-posture/test_validate.py
Normal 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()
|
||||
112
tools/tenancy-posture/validate.py
Normal file
112
tools/tenancy-posture/validate.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue