Tenancy Posture draft-6: the first review changed the document

tenant-engine assessed itself against the ladders and came back with
corrections. All five are adopted, because the ratification test says that if a
repo cannot express itself the ladders are wrong - and it could not, in three
places.

I2 conflated authority with verification. Its text named tenant-engine as the
source of existence, which made the level describing canonical identity
unclaimable by the service that provides it. An axis is assessed on a service's
own inbound surface, never on its authority over the concept. tenant-engine is
the source of tenant records and is at I1, because the acting identity arrives
in the request body rather than in a verified token.

A level now reports the weakest surface. tenant-engine has PDP-authorized
mutations and three unauthorized read routes - including the one flex-auth
calls for aal2-class decisions - and reported A2 rather than A3. Publishing the
stronger surface would be accurate about that surface and misleading about the
service. A per-surface vector was considered and rejected as premature.

The E ladder assumed all data is tenant-keyed. A registry whose rows ARE the
tenants has no predicate to scope a policy by, and enforcing one would break
the service rather than secure it. Mixed-shape services now declare an E level
plus a named registry exception; an unnamed exception is an overclaim. Without
this they overclaim or sit at E2 forever, which is what tenant-engine was
facing.

Retention and erasure are two dimensions and one level cannot carry both.
tenant-engine is R1 on backup and R0 on erasure - its lifecycle contract
deliberately defines no hard-delete, so a tenant record cannot be deleted ever,
by design, while carrying display_name and contact_email. Declared R1/R0 now.
And the compounding - personal data, no erasure path, a backup window set by
the longest-retaining co-resident - is the substrate owner's to surface,
because each part looks locally reasonable alone.

My own error, corrected: I listed tenant-engine as a live P1 occupant in both
the ladder and the E/P matrix. They are on SQLite. P1 is TEN-WP-0009's target
and the provisioning is my own unapplied intake. Asserting a placement that a
workplan exists to create is exactly the kind of claim this document forbids.

Worth recording: they found an unfiltered cross-tenant read in their own event
accessor while assessing against the ladder, before publishing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 21:38:35 +02:00
parent cb32f6d68c
commit 2744ce7d36

View file

@ -8,7 +8,7 @@ version: "0.1"
created: "2026-08-17"
updated: "2026-08-17"
scope: multi-tenancy-security-framework
revision: "draft-5"
revision: "draft-6"
adr:
- docs/adr/ADR-0006-recursive-multi-tenant-identity-authorization.md
- docs/adr/ADR-0013-tenant-onboarding-grouping-taxonomy.md
@ -24,7 +24,7 @@ related:
## Status
**Proposed, draft-5.** Relocated from `the-custodian/canon/architecture` on
**Proposed, draft-6.** 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.
@ -39,8 +39,14 @@ and the tenant-engine boundary contract, not in the work-factory canon.
- **draft-4** closed the two gaps draft-3 left open: `R4` had no mechanism
beyond waiting, and the noisy-neighbour evidence artifact asserted something
shared infrastructure cannot provide.
- **draft-5** relocates to NetKingdom and renames the dimensions from *planes*
- **draft-5** relocated to NetKingdom and renamed the dimensions from *planes*
to *axes*, because the word was already taken (§0).
- **draft-6** applies the first review. `tenant-engine` assessed itself,
corrected a guess downward on two axes, found an axis that did not fit its
data shape, and found a real cross-tenant defect in its own code while
reading the ladder. Five changes followed (§4.1, §4.4, §4.5, §5.2, and the E
registry exception). **The ratification test worked: the document changed,
not the repo.**
Every correction so far was found by research or by relocation, not by review.
@ -183,13 +189,22 @@ level is the one a service can evidence and its risk warrants.
|---|---|
| **I0** | No tenant concept. Data not attributable to a tenant. |
| **I1** | A local tenant notion exists but is not canonical, **or** the tenant is taken from the request rather than from a verified token. |
| **I2** | Canonical identifiers, bound at the identity provider and carried as a verified claim; `tenant-engine` is the source of existence. |
| **I2** | Canonical identifiers, bound at the identity provider and carried as a verified claim, **and verified by this service on its own inbound calls**. |
| **I3** | I2 plus capability roles honoured, with live `tenant-engine` re-query for privileged, destructive, credential-vending or `aal2`-class decisions. |
I1 now explicitly absorbs request-supplied tenant identifiers. "Never trust
client-supplied tenant IDs without validation" is a named anti-pattern; a
service reading the tenant from a header is at I1 however canonical the string.
**An axis is assessed on a service's own inbound surface, never on its
authority over the concept.** `tenant-engine` is the source of existence for
tenant records and is nonetheless at I1, because it takes the acting identity
from the request body rather than from a verified token. Draft-5 conflated
these by naming the authority inside the I2 definition, which made the level
describing canonical identity unclaimable by the service that provides it.
Corrected on tenant-engine's review — a reader would otherwise assume the
authority must be at I2 by definition.
`business-app-service-contract` §2.1 sets app-local accounts as the v1 baseline
for business apps — a sanctioned low level with recorded triggers for moving
up. That is the pattern this framework generalises.
@ -267,6 +282,20 @@ already established that our migration role owns the tables it creates), no
`EXPLAIN` comparison because RLS disables functional indexes built on
non-leakproof functions.
**Not all data is tenant-keyed, and the ladder must not pretend otherwise.** A
registry whose rows *are* the tenants has no per-tenant predicate to scope a
policy by; enforcing one would break the service's function rather than secure
it. `tenant-engine`'s `tenants` table is the worked example — `key-cape`
enumerates it at token issuance and `flex-auth` queries it live, both of which
are cross-tenant reads by design.
A service with mixed data shapes declares **E-level plus a registry
exception**: the level its tenant-keyed tables hold, and a named list of tables
excluded because they are registries rather than tenant data. The exception is
part of the claim and is reviewable; an unnamed exception is an overclaim.
Without this, mixed-shape services either overclaim or stay at E2 permanently,
and `tenant-engine` declined to claim E3 on precisely that reasoning.
**Default expectation** for a new platform service: E2 at first serve, E3
recorded as target. Services whose cross-tenant exposure would be a reportable
breach SHOULD target E3 or above.
@ -276,7 +305,7 @@ breach SHOULD target E3 or above.
| Level | Shape | Live occupants |
|---|---|---|
| **P0** | Shares a database with another consumer. | None sanctioned; the state absorbed repos arrive in. |
| **P1** | Database per consumer, shared cluster. | `audit-core`, `tenant-engine` on `platform-pg` |
| **P1** | Database per consumer, shared cluster. | `audit-core` on `platform-pg`; `tenant-engine` (target, TEN-WP-0009 — still on SQLite) |
| **P2** | Dedicated cluster per consumer. | `user-engine-pg`, `target-revenue-pg` |
| **P3** | Dedicated cluster per tenant. | Business apps per `business-app-service-contract` §1.2 |
| **P4** | P3 plus separate region or jurisdiction. | None |
@ -290,7 +319,7 @@ unreachable:
|---|---|---|---|---|---|
| **E4** | — | — | — | (business app) | |
| **E3** | | (target) | | | |
| **E2** | | tenant-engine<br>audit-core | | | |
| **E2** | (tenant-engine) | audit-core | | | |
| **E1** | (absorbed repo) | | | | |
| **E0** | | | | | |
@ -403,13 +432,26 @@ Worked examples, best-effort and subject to owner correction:
| Service | Current | Notes |
|---|---|---|
| `tenant-engine` | `I2 A3 E2 P1 R1` | Moving to P1 under TEN-WP-0009; retention declared, horizon not yet published. |
| `audit-core` | `I2 A3 E2 P1 R1` | Holds audit evidence, so both E3 and R2 are urgent targets. |
| `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` | `I2 A3 E2 P1 R1` | Guessed, not yet self-reported. Holds audit evidence, so E3 is urgent. |
| A newly absorbed repo | `I1 A1 E1 P0 R0` | Conformant **if declared**, with a recorded path. |
**Decision 5.1:** the posture vector is declared in the repo, not in the hub,
consistent with local-files-are-source-of-truth.
**Decision 5.2 — a level reports the weakest surface, not the best one.** A
service whose mutations are authorized by a PDP and whose read routes are
unauthenticated is at the read routes' level, not the mutations'. Publishing
the stronger surface would be accurate about that surface and misleading about
the service, which §6 forbids.
Raised by `tenant-engine`, which found exactly this shape in itself during
review — A3 on writes, no authorization on three read routes including the one
`flex-auth` calls for `aal2`-class decisions — and reported A2. A per-surface
vector was considered and rejected as premature: it multiplies the declaration
before anyone has shown the single weakest number is insufficient. Services
with a materially split surface should record the split in the `gap` field.
## 6. Conformance is accuracy, not altitude
> **A service is conformant when its declared posture is accurate, its target