From 06be56fdffd892342544e805e8de593133d6bb2c Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 17 Aug 2026 15:42:00 +0200 Subject: [PATCH] Take over the renderer; defer controlled disclosure to a risk service The renderer and its stylesheet moved in from the-custodian with a make build target, so T02 generalises something that works rather than starting from scratch. Publication tooling belongs to the repo that owns publication. Disclosure is resolved for now: full public is fine in build mode, where there are no users to expose and no attacker with anything to gain. Recorded as deferred rather than closed, because it stops being true at production - the same blast-radius disclosure that a consumer must read becomes a map once real tenant data exists. Controlled disclosure is deliberately not this repo's job. Publication is about permanence and currency; embargo is about severity, remediation and timing, and building it here would put risk judgement in the repo least qualified to make it. It likely wants a service of its own - a risk-nexus - with this repo as its publication surface rather than its brain. The only cost today is one line in T01: the addressing scheme must not assume every document is public from birth, so that adding an embargo state later is a new status rather than a URL migration. First publication retargeted - the framework relocated to NetKingdom canon and is now tenancy-posture_v0.1, five axes rather than five planes. Co-Authored-By: Claude Opus 5 --- INTENT.md | 8 +- Makefile | 15 + build/tenancy-posture.html | 383 ++++++++++++++++++ tools/render.py | 377 +++++++++++++++++ tools/style.css | 185 +++++++++ ...S-WP-0001-permanent-publication-surface.md | 59 ++- 6 files changed, 1004 insertions(+), 23 deletions(-) create mode 100644 Makefile create mode 100644 build/tenancy-posture.html create mode 100644 tools/render.py create mode 100644 tools/style.css diff --git a/INTENT.md b/INTENT.md index a134330..5a5d308 100644 --- a/INTENT.md +++ b/INTENT.md @@ -106,12 +106,10 @@ nobody. It reads; it does not write back. That direction is deliberate: a publication surface with write authority becomes a second source of truth, and the estate has a standing rule against exactly that. -The first content it must carry is already waiting: Custodian ADR-008 -(*Tenancy Posture*), which needs to reach six reviewing repos and is currently +The first content it must carry is already waiting: NetKingdom's *Tenancy +Posture* standard, which needs to reach six reviewing repos and is currently served from a disposable artifact URL. The renderer that produces that page -from canon markdown (`the-custodian/tools/render-artifact.py`) is a prototype -of what this repo generalises — and taking it over rather than reimplementing -it is the intended path. +from canon markdown now lives here as `tools/render.py`. ## What good looks like diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4271ed6 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: build clean + +# Publication targets. Source of truth is always the upstream repo; pages here +# are generated and must never be hand-edited. +SRC_NETKINGDOM ?= $(HOME)/net-kingdom + +build: + mkdir -p build + python3 tools/render.py $(SRC_NETKINGDOM)/canon/standards/tenancy-posture_v0.1.md \ + --output build/tenancy-posture.html \ + --title "Tenancy Posture" \ + --subtitle "A framework for describing, holding and improving multi-tenancy — including where we are not there yet." + +clean: + rm -rf build diff --git a/build/tenancy-posture.html b/build/tenancy-posture.html new file mode 100644 index 0000000..776bb14 --- /dev/null +++ b/build/tenancy-posture.html @@ -0,0 +1,383 @@ +Tenancy Posture + +
netkingdom-tenancy-posture proposed · draft-5generated from canon — do not edit

Tenancy Posture

A framework for describing, holding and improving multi-tenancy — including where we are not there yet.

Status

+

Proposed, draft-5. 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.

+
  • draft-1 proposed a single model with fixed characteristics. Rejected: it could not describe a repo that is not there yet.
  • draft-2 reframed to graduated levels per axis. Externally corroborated (§16), but four of its statements were wrong and one thing it needed was missing.
  • draft-3 applied those corrections, added the retention axis, and recorded an adoption stance.
  • 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 to axes, because the word was already taken (§0).
+

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.

+
+

00Terminology: axes, not planes

+

docs/platform-identity-security-architecture.md — accepted, 2026-07-23 — already uses plane for a trust and deployment layer: the bootstrap plane, the platform control plane, and tenant planes. That meaning is established, ratified, and owned by this repo.

+

Drafts 1–4 of this document, written elsewhere, used plane for something 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 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.
+

A workload in the tenant plane has a position on all five 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.

+
+

01Context

+

Drafts 1–4 opened by claiming the estate "has never written down what it is building". Relocation proved that wrong, and the correction is worth keeping visible: docs/platform-identity-security-architecture.md has described the trust model, the tenant model and a capability progression since 2026-07-23. The accurate claim is narrower — what was missing is a way to say how far a given service has got, and to hold several answers at once. Seven documents cover slices of the subject and none of them does that:

+
DocumentCoversStatus
iam-profile_v0.3 (NetKingdom)Tenant identifier shape, tenant_roles claim, staleness rulesRatified
tenant-engine-boundary-contract_v0.1 (NetKingdom)Who owns tenant records, roles, plan assignmentRatified
business-app-service-contract_v0.1 §1 (Custodian)Business apps: instance-per-client, tenant-keyed dataRatified
rapp-postgres ADR-0001Consumer + tenant isolation in PostgreSQLProposed, governs one repo
rapp-postgres ADR-0002Per-consumer retention and the erasure horizonProposed, governs one repo
shared-platform-relational-storage_v0.1The stacked-boundary gapRouted 2026-08-10, still unratified
platform-identity-security-architecture (NetKingdom)Trust model, planes, tenant model, capability progressionAccepted 2026-07-23
+

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.

+

The gap was diagnosed once and the fix stalled. The v0.1 draft was written to fill this hole and has sat unratified in neither canon directory. §20 attaches a ratification path so this one does not join it.

+

Placement is owned by nobody. user-engine-pg and target-revenue-pg are dedicated; apps-pg, net-kingdom-pg, platform-pg, state-hub-db and forgejo-db are shared. Both live, neither written down. tenant-engine raised this with railiance-platform on 2026-08-16; unanswered.

+

Two contradictory defaults are already ratified. Business apps get instance-per-client; platform services pool. Nothing says which shape a new service takes, and no definition separates the categories.

+

There is no honest way to describe a repo that is not there yet. The estate absorbs repos with weak or absent tenant separation. Today such a repo is simply non-conformant, leaving it two bad options: misrepresent its posture, or stay outside the framework.

+
+

02What this document is

+

A framework, not a model. It specifies no single correct implementation. It supplies terminology (§3, §4), a declaration (§5), a conformance rule (§6), methodology (§12), and evidence definitions (§13).

+

A service is conformant when its declared posture is accurate and its trajectory recorded. A service is non-conformant when it claims a level it cannot evidence — regardless of how high or low that level is.

+
+

03Five orthogonal axes

+

"Is this multi-tenant?" is treated as one question. It is five, and they are independent:

+
AxisQuestionVocabulary owner
Identity (I)How is a tenant named and validated?tenant-engine / IAM Profile
Authorization (A)How is a request bound to the tenants it may act for?flex-auth
Enforcement (E)Where, mechanically, is the tenant boundary enforced?This framework
Placement (P)Which substrate holds a tenant's data?railiance-platform
Retention (R)How long does data persist, and how is it erased?The storage platform; policy by the consumer
+

Conflation produces errors today. rapp-postgres's PostgresConsumer carries tenantIsolation: consumer-service-boundary — an E-axis fact in a P-axis artifact, reading as though storage enforces something it does not. The "dedicated versus shared" argument mixes P (capacity, blast radius) with E (correctness).

+

The axes are separated precisely so each may sit at a different level.

+

Decision 3.1: every document, declaration and plan tier that says "isolation" MUST name which axis it means.

+

Decision 3.2: the axes couple at their tops and the couplings MUST be stated where they apply, not used to argue the axes are one:

+
  • E4 is reachable only at P3 or above.
  • R's erasure horizon is bounded below by P — on shared substrate, a consumer's horizon is the instance maximum (§4.5).
  • R4 by key destruction is bounded by the key boundary, which is an E-axis property. Shredding a single tenant's data requires the application to encrypt under a per-tenant key before writing; the storage platform cannot supply it. Reaching the top of the retention ladder is not a retention project.
+

Decision 3.3 — scope. The P and R ladders describe a service's primary datastore. Caches, search indices, message queues and background jobs are named leak surfaces in the external baselines and are assessed separately, not covered by a posture vector. Saying so is honest; implying the vector covers them would not be.

+
+

04Graduated levels

+

Each axis carries an ordered ladder. Higher is stronger, not better: the right level is the one a service can evidence and its risk warrants.

+

4.1 Identity (I)

+
+
Identityplane I
I0No tenant concept. Data not attributable to a tenant.
I1A local tenant notion exists but is not canonical, or the tenant is taken from the request rather than from a verified token.
I2Canonical identifiers, bound at the identity provider and carried as a verified claim; tenant-engine is the source of existence.
I3I2 plus capability roles honoured, with live tenant-engine re-query for privileged, destructive, credential-vending or aal2-class decisions.
Ladder ends at I3.
+
+

I1 now explicitly absorbs request-supplied tenant identifiers. "Never trust client-supplied tenant IDs without validation" is a named anti-pattern; a service reading the tenant from a header is at I1 however canonical the string.

+

business-app-service-contract §2.1 sets app-local accounts as the v1 baseline for business apps — a sanctioned low level with recorded triggers for moving up. That is the pattern this framework generalises.

+

4.2 Authorization (A)

+
+
Authorizationplane A
A0No authorization, or tenant context not carried.
A1Ad-hoc checks scattered through handlers.
A2A single local authorization boundary; tenant context bound once, centrally.
A3Decisions delegated to flex-auth as PDP, with live re-query where the IAM Profile requires it.
A4A3 over a standard PDP interface (OpenID AuthZEN Authorization API 1.0), so the decision point is swappable and the enforcement point is not coupled to one engine's request shape.
+
+

A4 is new. flex-auth uses a bespoke CheckRequest and a bespoke action vocabulary, with action strings copied verbatim between repos to avoid re-derivation — exactly the coupling AuthZEN removes. The specification reached Final in January 2026 and Keycloak shipped experimental support in May. We are not wrong, we are pre-standard, and the ladder should have somewhere to go.

+

Internal service-to-service calls are in scope for this axis. "Skipping tenant validation for internal services" is a named anti-pattern, and our estate is mostly internal calls — flex-auth calls tenant-engine synchronously on the authorization path. A service identity acting on behalf of a tenant must carry and revalidate tenant context to claim A2 or above.

+

4.3 Enforcement (E)

+
+
Enforcementplane E
E0None. Data not tenant-keyed; separation incidental or absent.
E1Data tenant-keyed, filtering applied per query at call sites.
E2Filtering centralised at a single service-side choke point binding authenticated identity to permitted tenants.
E3E2 plus platform-assisted filtering: row-level security keyed on a tenant GUC set transaction-locally, or an equivalent enforced data-access layer.
E4Structural: the credential a workload holds cannot address another tenant's data at all. Requires per-tenant credentials and per-tenant substrate.
+
+

Correction from draft-2. Draft-2 described E3 as something "the application cannot trivially route around". That is false and it was this document overclaiming in exactly the way §6 prohibits. Any session can re-issue SET on a custom GUC, so an attacker with SQL execution can reset the tenant and read across the boundary. What E3 buys is precise, and the ladder must say so:

+
ThreatE1E2E3E4
A developer forgets a tenant predicate
A new code path bypasses the choke point
SQL injection reaching the connection
The application process is compromised
+

E3 is a strong control against accident — the common case, and the one that causes real breaches — and no control at all against compromise. Only E4 holds against both, because the credential itself cannot address another tenant's data.

+

Correction: E3 layers on E2, it does not replace it. External practice treats application-layer and database-layer filtering as complementary. A service that dropped its choke point on reaching E3 would be worse off, since E3 fails open under injection. Claiming E3 therefore requires the E2 evidence artifact as well.

+

Correction: the GUC is set transaction-locally. Draft-2 said "at pool checkout", which is session scope and the wrong instrument. Under a pooler in statement mode, SET leaks between clients and returns other tenants' rows — a failure that appears only under production concurrency and produces no error. Use SET LOCAL inside an explicit transaction.

+

Platform enforcement is a platform obligation. Reaching E3 requires the storage platform to offer the mechanism: provisioned policies, a documented GUC contract, and a probe. Where a consumer wants E3 and the platform has not supplied it, the gap is the platform's. §19.6 asks rapp-postgres to define that contract, which must carry FORCE ROW LEVEL SECURITY on every tenant table (without it the table owner bypasses policies silently, and ADR-0001 already established that our migration role owns the tables it creates), no BYPASSRLS on leased roles, SECURITY INVOKER for ordinary logic, and an EXPLAIN comparison because RLS disables functional indexes built on non-leakproof functions.

+

Default expectation for a new platform service: E2 at first serve, E3 recorded as target. Services whose cross-tenant exposure would be a reportable breach SHOULD target E3 or above.

+

4.4 Placement (P)

+
+
Placementplane P
P0Shares a database with another consumer.
P1Database per consumer, shared cluster.
P2Dedicated cluster per consumer.
P3Dedicated cluster per tenant.
P4P3 plus separate region or jurisdiction.
+
+

Enforcement and placement are independent axes. Plotted together, with where each service actually sits — parenthesised entries are targets or defaults rather than current positions, and marks a cell the coupling in §3.2 makes unreachable:

+
Enforcement →
E4
business app
E3
target
E2
tenant-engineaudit-core
E1
absorbed repo
E0
P0
P1
P2
P3
P4
Where a service sits todayTarget or defaultUnreachable at this placement
+

P0 → P1 → P2 is movement along the horizontal axis only. Those steps buy consumer isolation, capacity predictability, independent retention and a smaller operational blast radius. They do not raise the tenant boundary by one step. Only P3 makes E4 reachable. This is the most misusable fact in the framework and §11 governs how it may be described.

+

Decision 4.4.1: P1 is the default for platform services; P3 for client-facing business apps, as already ratified. A service unsure which it is must resolve that first (§19.4).

+

Decision 4.4.2 — placement scopes to data substrate. Identity-provider placement (realm-per-tenant versus Organizations) is the same silo/pool decision on a different substrate, is live in our estate, and is undecided. Realm-per-tenant carries a stated ceiling around 5–20 tenants, far below our target. Recorded here as a parallel question (§19.7), not folded into P.

+

4.5 Retention and erasure (R)

+

New in draft-3. Implemented abstractly by the storage platform for any dataset; policy is built on top of that interface by the consumer or its governance layer. Reference implementation: rapp-postgres ADR-0002.

+
+
Retentionplane R
R0No retention or deletion position. Data kept indefinitely by default; no deletion path exists.
R1Platform default retention applies (N=30 days). The consumer has declared no requirement.
R2Retention declared as N days per dataset; the erasure horizon is published, and the consumer makes no promise shorter than it.
R3Policy-driven deletion: the consumer or its governance layer declares what is due, the platform sweeps whole datasets on that instruction and evidences each run.
R4Verified erasure: data proven unrecoverable across live storage, backups and derived copies, by one of the two routes below.
+
+

R4 has two routes and a service MUST name which one it uses.

+
RouteMechanismCost
Horizon-elapsedWait out the published erasure horizon; the data ages out of every retained copy.Available to everyone, proves little, and the wait is set by a co-resident's retention requirement rather than your own.
Key-destroyedEncrypt per entity, then destroy the key. Retained copies survive but are unreadable.Requires per-entity keys, strong encryption, and an auditable destruction record. Immediate.
+

Regulatory standing of the key-destroyed route, stated carefully because overclaiming here is worse than anywhere else in this framework. Data protection authorities have accepted key destruction as erasure where physical deletion would be manifestly disproportionate, and the practice is recognised under conditions — strong encryption, irreversible destruction, and an auditable record of it. The EDPB has not formally endorsed it as Article 17 erasure. A service reaching R4 by key destruction is making a defensible claim, not a settled one, and must say so rather than reporting a clean "deleted".

+

Three further properties.

+

The erasure horizon is the interval between deleting data and it ceasing to be recoverable from anything the platform holds. Deleting a row does not remove it from yesterday's backup. With an N-day window, deleted data remains recoverable for N days. That is the difference between "deleted" and "erased" and the estate had never written it down.

+

On shared substrate, retention is not per-consumer. Physical backup is instance-wide — one WAL stream, one window — so the instance retention is derived as the maximum across co-resident consumers, and every consumer's horizon is that maximum. A consumer declaring 7 days beside one declaring 90 gets 90. This is the retention analogue of ADR-0001's blast-radius disclosure: state the coupling rather than imply an isolation that is not there.

+

Retention is therefore a placement trigger. A consumer needing a horizon shorter than the instance floor cannot have one at P1. It moves to P2 for a reason with nothing to do with performance — which is exactly why it needs recording, since nobody looks for a retention argument when reviewing placement.

+

Deletion splits mechanism from policy. The platform deletes whole datasets on instruction and records an opaque policy reference it never interprets, so every deletion traces to what authorised it. Rows are not a dataset: row expiry is the consumer's own DML under its migration lease. Dropping a consumer's whole database is an operator-gated offboarding step, never a scheduled one.

+
+

05The posture vector

+

A service states one level per axis, plus a target, a date, and any placement exceptions:

+
tenancy:
+  current:  { I: 2, A: 3, E: 2, P: 1, R: 1 }
+  target:   { I: 2, A: 3, E: 3, P: 1, R: 2 }
+  reviewed: "2026-08-17"
+  gap:
+    E: "Choke point exists and is tested; RLS not provisioned. Blocked on
+        rapp-postgres publishing the GUC contract. Target Q4."
+    R: "Retention declared; erasure horizon not yet published to consumers."
+

Placement exceptions. Draft-2 assigned one P level per service, which cannot express the vertically partitioned model — most tenants pooled, some dedicated — that §11's isolation tiers require. A tier requiring P2 bought by three tenants would put the service at two levels at once, forcing an over- or under-claim. Placement is therefore declared as a default plus exceptions:

+
  placement_exceptions:
+    - tenants: ["tenant:enterprise:*"]
+      P: 3
+      reason: "isolation tier; see adaptive-pricing tier definition"
+

A service with exceptions must be able to say which tenants are on which substrate. That mapping is a first-class artifact, not archaeology.

+

Worked examples, best-effort and subject to owner correction:

+
ServiceCurrentNotes
tenant-engineI2 A3 E2 P1 R1Moving to P1 under TEN-WP-0009; retention declared, horizon not yet published.
audit-coreI2 A3 E2 P1 R1Holds audit evidence, so both E3 and R2 are urgent targets.
A newly absorbed repoI1 A1 E1 P0 R0Conformant 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.

+
+

06Conformance is accuracy, not altitude

+

A service is conformant when its declared posture is accurate, its target is recorded, and it does not claim a level it cannot evidence. It is non-conformant when it overclaims — at any altitude.

+
  • Declaring E0 is conformant. Concealing E0 is not.
  • A repo may be absorbed at any posture. It may not be absorbed silently.
  • No service is blocked from the estate for being low on a ladder. Services MAY be blocked from specific work — serving a tenant grouping, holding a data class, carrying a plan tier — by requirements expressed as minimum levels.
  • Downgrading is permitted and must be declared. A regression found by guarding is a defect; a regression declared in advance is a decision.
+

Without the 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.

+
+

07Portability across placement levels

+

Movement between P levels must be operational, not a rebuild:

+
  • Connect by injected credential only — no cluster, host, namespace or database name in source.
  • Own a whole database, never tables inside someone else's.
  • Idempotent schema creation.
  • No cross-database joins or co-location assumptions.
+

Decision 7.1: mandatory at P1 and above. At P3, SHOULD rather than MUST — a per-client instance that never moves is not misconformant for naming its own database.

+
+

08Placement triggers

+

Recorded at provisioning time: noisy neighbour on a latency-critical path; a compliance or residency requirement; a plan tier requiring a higher minimum; an erasure horizon that no longer fits (§4.5); connection or memory ceiling reached.

+

Decision 8.1: triggers MUST be monitored, not merely recorded. A trigger in a YAML comment nobody re-reads is documentation, not control.

+

Decision 8.2: placement policy ownership is proposed to railiance-platform, co-signed by adaptive-pricing. Tenancy model selection is a commercial decision as much as a technical one; an operations-shaped repo should not hold it alone.

+
+

09Credentials as a tenancy control

+

Short-lived leased credentials re-read at connection checkout, with overlap-first rotation, bound the residual risk at every E level below E4: a leaked credential expires rather than persisting. Stronger than the industry norm of a long-lived per-service secret.

+

Decision 9.1: static long-lived database credentials are not a sanctioned path for any service above E0.

+
+

10Blast radius must be published

+

Decision 10.1: every platform holding consumer data MUST publish, in concrete terms, what a leaked runtime credential can and cannot reach at the levels it operates. rapp-postgres ADR-0001 §5 is the reference. Where the model cannot provide a guarantee, the platform says so and names the escalation.

+

Decision 10.2 — quotas are disclosed, not discovered. The same obligation extends from what a leaked credential can reach to what the platform will refuse to do for you. Every consumer MUST be told, at provisioning, the throttles and quotas enforced against it — connection limits, statement timeouts, idle-transaction timeouts — and told again when they change. A consumer learning its statement timeout by hitting it in production is a disclosure failure, not a consumer bug. This is how tenant-engine was provisioned, by good practice rather than by rule; the rule now exists.

+
+

11Commercial expression

+
  • 11.1 Plan tiers are expressed internally as minimum levels. A tier may require E3 P2 R2; it need not print that anywhere customer-facing.
  • 11.2 Marketing and product language is free. No requirement to expose level labels or this document. "Dedicated infrastructure", "isolated tenancy", "private instance" all remain available.
  • 11.3 The constraint is on evidence, not vocabulary. A customer-facing isolation, availability or retention claim must map to a minimum level the delivering service actually holds, recorded once when the tier is defined. The review is internal and happens at tier definition — not per campaign.
  • 11.4 Two hard lines, because these reach contracts and compliance questionnaires:
  • A claim that another tenant cannot reach the customer's data requires E4.
  • A claim that deleted data is gone requires R4, or an erasure horizon disclosed alongside it. Where R4 is reached by key destruction, the claim is defensible but not settled law (§4.5) — it may be made, and it may not be made in language that implies a regulator has blessed it.
+
+

12Methodology — analyze, establish, improve, guard

+

Analyze. Assess a repo against the ladders; produce tenancy.current with reasoning recorded. Applies to new and absorbed services alike.

+

Establish. Declare the target and gap. The target is set by data class, tenant groupings served and plan tiers carried — not by ambition.

+

Improve. Move one axis at a time. Raising P while leaving E untouched is the characteristic misstep.

+

Guard. Verify continuously that the declared posture holds — against the service's own declaration, not a universal maximum. Nobody must prove every service is at E4; the check is that none is below what it declared.

+

Regression found by guarding is a defect; regression declared in advance is a decision. The estate has been bitten twice by silent pin rollbacks producing ordinary-looking 403s and 404s rather than errors. Posture regression looks the same — an RLS context leak returns correct-looking rows for the wrong tenant. Guarding must be designed for invisible failure, not for crashes.

+
+

13Evidence per level

+

Decision 13.1: a level is claimed only with its evidence artifact present. This turns §6's accuracy rule from an honour system into a check.

+

Decision 13.4 — an artifact must assert something achievable. Draft-3's noisy-neighbour evidence required proof that a saturating consumer "does not breach" another's allowance. Shared infrastructure cannot provide that; the risk is inherent and cannot be wholly removed. An artifact that can only fail, or that passes by being run gently enough, is an overclaim wearing the costume of evidence. Where a property cannot be guaranteed, the artifact measures and records it instead.

+

Decision 13.2 — evidence is of two kinds, and conflating them is an overclaim. Mechanical evidence is a structural assertion a machine can make and belongs in CI. Adversarial evidence is semantic, requires setting up separate tenant contexts and comparing responses, and carries a review date rather than a green build. Cross-tenant findings are the category external testing practice identifies as needing human review. A passing CI run is not E2 evidence.

+
LevelEvidenceKind
I2Identifiers validated against the vocabulary; rejection test for a malformed id; binding shown to come from a verified tokenMechanical
I3Live re-query demonstrated on an aal2-class path; cached-claim path shown unused thereMechanical
A2Choke point identified; test that an unbound request is refusedMechanical
A3Live decision with a denial observed at the endpoint, not only at the decision surfaceMechanical
A4Decision served over the standard interface; a second PDP substituted without PEP changeMechanical
E1Every tenant-owned table carries the tenant keyMechanical
E2Choke point identified; identity bound to tenant A demonstrably cannot read tenant BAdversarial, with a review date
E3FORCE 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 comparisonMechanical
E4Per-tenant credential demonstrated unable to connect to another tenant's substrateMechanical
P1–P4Provisioning declaration plus the platform's isolation probesMechanical
P1–P2 (noisy neighbour)A recorded baseline of per-consumer resource usage; a run in which one consumer saturates its declared allowance; evidence that the governance controls bind (the greedy consumer is held at its limits) and that the degradation co-residents experience is measured, recorded and judged acceptable; the aggregate headroom at time of measurementAdversarial, load-generated, with a review date
R2Declared retention rendered; erasure horizon published and reported in the operator surfaceMechanical
R3Sweep evidence records: timestamp, dataset, identifiers removed, authorising policy referenceMechanical
R4Erasure demonstrated across live data, backups and derived copies within the horizonAdversarial
+

Decision 13.3: the E2, E3 and noisy-neighbour artifacts do not exist anywhere in the estate today. rapp-postgres runs 15 adversarial probes, all against the consumer boundary, none against the tenant boundary inside a consumer. Externally, what this framework calls a tenant boundary failure is Broken Object Level Authorization — OWASP API1, top of the API Security Top 10 since that list launched, and the most commonly exploited API vulnerability in published assessments. We have no coverage for the highest-ranked risk in our class of system. §19.3 seeks an owner.

+
+

14Adoption stance — structure, not tooling

+

Decision 14.1: external research is design input. This estate adopts published standards and structural patterns; it does not adopt tooling unless that tooling is an established industry standard with broad application. Everything else is built ground-up, so it can be optimised and refactored as the estate sees fit.

+
ClassStance
Security baselines (OWASP Multi-Tenant Security Cheat Sheet, API Security Top 10)Adopt as the external reference our ladders answer to
Standards bodies (OpenID AuthZEN 1.0)Adopt — this is what A4 is
Reference taxonomies (Azure tenancy models, AWS SaaS Lens, cell architecture)Adopt as structure
Engine behaviour (PostgreSQL RLS mechanics)Facts, not tooling
Third-party analyzers and test frameworksDo not adopt. Take their rule taxonomies as checklists for probes we write ourselves
+

The practical effect is small and good: rapp-postgres already owns a ground-up probe harness — bash and psql, no dependency tree — that found four real defects in its own provisioning SQL. The evidence artifacts in §13 become new probes in a tool we control. One idea worth reimplementing from the external survey is policy-diff classification: labelling a change to an enforcement policy as safe or breaking before it lands.

+
+

15Alternatives considered

+

One fixed model with a single set of characteristics (draft-1). Rejected: cannot describe a repo that is not there yet, forcing absorbed repos to misrepresent their posture or stay outside. A framework that can only describe its own end state is not a framework.

+

A maturity model with a single overall level. Rejected: collapses the axis separation. A service strong on identity and weak on enforcement has a specific, actionable gap; one composite score hides it and invites averaging.

+

Prohibiting row-level security (draft-2's inherited position). Rejected in draft-2, refined in draft-3: RLS is a real rung against the common threat. The error was never RLS — it was describing E3 in E4's language.

+

Schema-per-consumer in one database. Rejected: pg_catalog is readable per-database, so every co-resident enumerates every other's table and column names regardless of grants. Retained as a describable state, never a target.

+

Mandating E4 for everyone. Rejected: the tenant taxonomy includes consumer (private individuals) and family. A cluster per private individual is economically impossible; the taxonomy is itself evidence pooling is required.

+

Per-consumer physical backup retention. Rejected: CNPG retention is a property of the instance's WAL archive. There is no mechanism, and claiming it would be a fabricated guarantee. Hence the derived maximum in §4.5.

+

Platform-scheduled row expiry. Rejected: requires the platform to hold DML authority over consumer schemas and interpret consumer data semantics, both forbidden by ADR-0001. The consumer's migration lease is the correct instrument.

+

Leaving each repo to its own model. Rejected: the status quo, which produced two contradictory ratified defaults and an unowned placement question.

+
+

16Held against outside practice

+

The graduated reframe is corroborated, not invented here. Microsoft's tenancy-model guidance states it almost verbatim: "Instead of viewing isolation as a discrete property, consider it a spectrum. You can deploy components of your architecture that are more isolated or less isolated than other components in the same architecture." The same guidance derives our E↔P coupling independently — shared deployment means enforcement lives in application code; dedicated deployment means it is structural.

+

Stronger than typical. Most multi-tenancy literature models one boundary, tenant-to-tenant. This estate has two stacked boundaries: platform-service to platform-service, and tenant to tenant inside a consumer. Naming them separately and refusing to enforce both with one mechanism is uncommon and correct. Graduated per-axis levels also beat the silo/pool/bridge trichotomy, which is approximately our P axis with the other four missing — which is why it cannot express "pooled infrastructure, structurally enforced boundary".

+

Weaker than typical. The pool model's standard mitigation is a verified enforcement layer every service is demonstrably routed through. We have the concept and none of the verification (§13.3).

+

Adopted without naming it. Short-lived leased credentials re-read at checkout beat the long-lived-secret norm. §9 promotes it to a tenancy control.

+

Still unexplored. Neither P nor R describes a cell — a slice of infrastructure with a fixed maximum size, sized so one cell's failure is survivable and cell count scales linearly. platform-pg is, in these terms, an uncapped cell: §17 computes a ceiling and nothing enforces it (§19.8).

+

Sources: the four research digests in research/2026-08-17-adr008-*, which carry full citations for every claim in this section.

+
+

17Scaling demands

+

Measured against the live platform-pg specification, not estimated.

+
instances:        1              (no HA; single-node rail)
+max_connections:  100
+memory limit:     1Gi
+per consumer:     14 connections (12 runtime + 2 migration)
+

Connection ceiling: roughly six consumers — and this is the aggregate noisy-neighbour bound, not a capacity statistic. Seven consumers request 98 of 100 before CNPG's instance manager, metrics exporter and reserved slots. Every one of them is politely inside its declared 14-connection allowance; the instance still fails.

+

That distinction matters because our governance addresses the wrong shape. Per-consumer connection_limit, statement_timeout and idle_in_transaction_session_timeout guard well against one greedy consumer. They do nothing about the aggregate of many modest ones, which is the second and less intuitive noisy-neighbour failure and the one this number describes. Two consumers are provisioned. We are at roughly a third of the bound, and the third request will not feel like a scaling event.

+

Memory likely binds first. 100 backends against 1Gi is ~10MB per backend. Connection exhaustion errors clearly; memory pressure OOM-kills and degrades every co-resident at once.

+

E3 and pooling. Corrected from draft-2, which had this backwards. Transaction-scoped context (SET LOCAL inside an explicit transaction) is what makes E3 safe under a pooler. Statement-level pooling is what breaks it, serving other tenants' rows under concurrency with no error. E3 constrains which pooling mode is available, not whether pooling is available.

+

Retention consumes the volume. WAL accumulates with the window, and §4.5 makes the window the maximum across consumers. A consumer declaring a long retention extends everyone's horizon and everyone's storage draw against a 20Gi volume.

+

Restore time couples all consumers. Physical backup is instance-wide, so a consumer's RTO is a function of total instance size, not its own.

+

No P1 tenant has HA. instances: 1 means a tier promising uptime cannot be satisfied at P1 as built — an availability floor belongs in §11's minimum-level vocabulary alongside isolation.

+
+

18Consequences

+
  • The estate gains one vocabulary and a way to be honest about partial adoption.
  • Absorbed repos get a described state and a path instead of a failing grade.
  • tenantIsolation in PostgresConsumer is revealed as a mislabelled field.
  • The verification problem becomes tractable: guard against declaration.
  • Draft-2's RLS prohibition is reversed and its E3 description corrected; rapp-postgres acquires an obligation to define and offer the mechanism.
  • Adding a consumer with long retention silently extends everyone's erasure horizon. This must reach the consumer review checklist, not only this document.
  • A service selling an isolation tier must maintain a tenant→substrate mapping it does not have today.
  • Nothing here changes a running system.
+
+

19Open questions

+
  1. tenantIsolation fieldrapp-postgres: rename to name its axis and carry a level (tenancy.E: 2), or move it out of the storage declaration.
  2. Placement ownershiprailiance-platform with adaptive-pricing: accept the ladder, triggers and the §8.1 monitoring obligation; appoint a recorded placement owner per workload.
  3. E2, E3 and noisy-neighbour evidenceowner needed. Now three artifacts of two kinds: E3 and noisy-neighbour are buildable as probes in the existing harness; E2 is adversarial and needs a review cadence. Both audit-core and tenant-engine have declined fleet-scope work on correct boundary reasoning, so this needs appointing. Highest-severity gap.
  4. Business app vs platform service — Custodian canon: a classification rule. Candidate: reuse repo-classification-standard_v1.0.
  5. Tier → minimum level mappingadaptive-pricing and tenant-engine: required only for tiers making isolation, availability or retention claims.
  6. The E3 mechanismrapp-postgres: publish the GUC contract with the FORCE/BYPASSRLS/SECURITY INVOKER/EXPLAIN requirements in §4.3.
  7. Identity-provider placement — owner of key-cape: realm-per-tenant or Organizations? Realm-per-tenant's ~5–20 tenant ceiling is below our target.
  8. Cell sizing — reframed from "should we adopt cells" to "what is platform-pg's declared maximum size, and what is the overflow target?" The connection ceiling forces this whether or not we adopt the vocabulary.
  9. Retention floor and ceiling — should backupRetentionDays have a platform minimum (so a consumer asking for 1 day gets a validation error rather than a quiet disappointment) and a maximum (so nobody exhausts the volume)?
  10. Engine neutrality — the P ladder rests on a PostgreSQL property. State it engine-specifically and say so, or abstract it and risk a non-Postgres implementation that silently differs?
  11. Erasure versus auditaudit-core: crypto-shredding a tenant's audit records destroys the evidence the service exists to hold, and ADR-0001 §2 deliberately built the role model so history could not be rewritten. The usual resolution separates the fact of an event, retained, from its personal payload, encrypted per subject and shreddable. Raised because a naive "R4 everywhere" target would instruct the audit service to destroy its own evidence. The answer is audit-core's, not this framework's.
  12. Quality of serviceowner needed. The framework has no vocabulary for saying one consumer's latency matters more than another's. tenant-engine sits on flex-auth's synchronous authorization path and chose a 5s statement timeout for that reason; it shares an instance with audit-core, which is not latency-critical. Nothing prioritises between them. Either add a QoS dimension or state that all co-residents are equal and latency-critical consumers must escalate to P2.
+

Routed elsewhere, deliberately. The tenant identifier tenant:<grouping>:<name> embeds headcount bands (small, medium, large) that change as a tenant grows, contradicting the consensus that identifiers should not encode mutable attributes. That is a critique of ADR-0013, not of this framework, and belongs to tenant-engine and NetKingdom canon. Folding it in here would overreach.

+
+

20Ratification path

+
  1. Reviewed by tenant-engine, flex-auth, rapp-postgres, railiance-platform and adaptive-pricing against §19.
  2. Each publishes its own posture vector (§5) as part of review. The framework is validated by whether it can describe them accurately — if a repo cannot express itself in these five ladders, the ladders are wrong and this document changes, not the repo.
  3. On acceptance, supersedes the routing of rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1-draft.md, whose §§3–8 are absorbed here. That draft is withdrawn rather than left pending.
  4. On acceptance, rapp-postgres ADR-0001 and ADR-0002 move to accepted and are annotated as the PostgreSQL implementation of the E, P and R ladders.
+
netkingdom-tenancy-posture · draft-5 · proposedgenerated from the-custodian/canon
diff --git a/tools/render.py b/tools/render.py new file mode 100644 index 0000000..9069bae --- /dev/null +++ b/tools/render.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Render a canon markdown document into a styled, self-contained artifact page. + +Single source of truth: the markdown. The page is generated, never hand-edited, +so the two cannot diverge. + +Design devices are recognised from conventions already present in the markdown +rather than from extra markup, so the source stays a readable document: + + * a table whose first column is `**X0**`/`**X1**`... renders as a level ladder + * a table whose first header cell is `Threat` renders as a threat matrix + * a table with a `Kind` column renders with mechanical/adversarial chips + * a table whose first header cell is `E \\ P` renders as the E x P matrix + * a blockquote renders as a pull quote + * `**Decision N.N...**` at the start of a paragraph renders as a decision + * `## N. Title` headings build the section rail + +Stdlib only, per the estate's structure-not-tooling stance: a publishing step +that needs its own toolchain is a publishing step that stops being run. + +Usage: + python3 tools/render-artifact.py --output \\ + [--title "Name"] [--subtitle "..."] +""" + +from __future__ import annotations + +import argparse +import html +import pathlib +import re +import sys + +STYLE = pathlib.Path(__file__).parent / "style.css" + +INLINE_CODE = re.compile(r"`([^`]+)`") +BOLD = re.compile(r"\*\*([^*]+)\*\*") +EM = re.compile(r"(? str: + """Escape, then apply inline markdown. Order matters: code first.""" + slots: list[str] = [] + + def stash(rendered: str) -> str: + slots.append(rendered) + return f"\x00{len(slots) - 1}\x00" + + text = INLINE_CODE.sub(lambda m: stash(f"{html.escape(m.group(1))}"), text) + text = html.escape(text, quote=False) + text = LINK.sub( + lambda m: f'{m.group(1)}', text + ) + text = BOLD.sub(r"\1", text) + text = EM.sub(r"\1", text) + for i, rendered in enumerate(slots): + text = text.replace(f"\x00{i}\x00", rendered) + return text + + +def split_frontmatter(source: str) -> tuple[dict, str]: + if not source.startswith("---\n"): + return {}, source + end = source.index("\n---\n", 4) + meta = {} + for line in source[4:end].splitlines(): + if ":" in line and not line.startswith((" ", "-")): + key, _, value = line.partition(":") + meta[key.strip()] = value.strip().strip('"') + return meta, source[end + 5 :] + + +def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]: + rows, i = [], start + while i < len(lines) and lines[i].lstrip().startswith("|"): + cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + if not all(set(c) <= set("-: ") for c in cells): + rows.append(cells) + i += 1 + return rows, i + + +# --- table renderers ------------------------------------------------------- + + +def render_ladder(rows: list[list[str]]) -> str: + """A level table becomes a stepped scale. Colour depth encodes strength.""" + body = rows[1:] + plane = LEVEL_CELL.match(body[0][0]).group(1) + names = { + "I": "Identity", "A": "Authorization", "E": "Enforcement", + "P": "Placement", "R": "Retention", + } + rungs = [] + for cells in body: + match = LEVEL_CELL.match(cells[0]) + if not match: + continue + n = int(match.group(2)) + text = cells[1] if len(cells) > 1 else "" + rungs.append( + f'
{plane}{n}' + f'{inline(text)}
' + ) + while len(rungs) < 5: + rungs.append('
' + f'Ladder ends at {plane}{len(rungs) - 1}.
') + return ( + '
' + f'{names.get(plane, plane)}plane {plane}
' + f'
{"".join(rungs)}
' + ) + + +def render_threat(rows: list[list[str]]) -> str: + head = "".join(f"{inline(c)}" for c in rows[0]) + body = [] + for cells in rows[1:]: + tds = [f"{inline(cells[0])}"] + for c in cells[1:]: + cls = "yes" if "✓" in c else "no" if "✗" in c else "" + tds.append(f'{inline(c)}') + body.append(f"{''.join(tds)}") + return (f'
{head}' + f'{"".join(body)}
') + + +def render_matrix(rows: list[list[str]]) -> str: + """`E \\ P` table becomes the two-axis grid. Cells hold pins or markers.""" + cols = rows[0][1:] + cells = ['
'] + for cells_row in rows[1:]: + e = cells_row[0].strip("*") + level = int(e[1]) if len(e) > 1 and e[1].isdigit() else 0 + cells.append(f'
{html.escape(e)}
') + for value in cells_row[1:]: + v = value.strip() + if v == "—": + cells.append('
') + continue + tint = f" tint{level}" if level else "" + pins = "" + for entry in (p.strip() for p in v.split("
") if p.strip()): + ghost = " ghost" if entry.startswith("(") else "" + pins += f'{inline(entry.strip("()"))}' + cells.append(f'
{pins}
') + cells.append('
') + cells.extend(f'
{html.escape(c)}
' for c in cols) + cells.append("
") + return ( + '
Enforcement →
' + + "".join(cells) + + "
" + '
' + 'Where a service sits today' + 'Target or default' + 'Unreachable at this placement' + "
" + ) + + +def render_table(rows: list[list[str]]) -> str: + if not rows: + return "" + header = [c.strip() for c in rows[0]] + first = header[0].lower() + if first.replace(" ", "") in {"e\\p", "e\\p"}: + return render_matrix(rows) + if first == "threat": + return render_threat(rows) + kind_col = header.index("Kind") if "Kind" in header else None + # A level table is a ladder. The evidence table also leads with `Level` but + # carries a `Kind` column, and is a table of artifacts, not of rungs. + if first == "level" and kind_col is None and len(rows) > 1 and LEVEL_CELL.match(rows[1][0]): + return render_ladder(rows) + head = "".join(f"{inline(c)}" for c in header) + body = [] + for cells in rows[1:]: + tds = [] + for i, c in enumerate(cells): + if i == kind_col: + adv = "adv" if "adversarial" in c.lower() else "" + label = re.sub(r"[*_]", "", c).strip() + tds.append(f'{html.escape(label)}') + else: + tds.append(f"{inline(c)}") + body.append(f"{''.join(tds)}") + return (f'
{head}' + f'{"".join(body)}
') + + +# --- document --------------------------------------------------------------- + + +def render_body(markdown: str) -> tuple[str, list[tuple[str, str, str]]]: + lines = markdown.splitlines() + out: list[str] = [] + rail: list[tuple[str, str, str]] = [] + open_section = False + ladders_open = False + i = 0 + + def close_ladders() -> None: + nonlocal ladders_open + if ladders_open: + out.append("") + ladders_open = False + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped: + i += 1 + continue + + heading = HEADING.match(stripped) + if heading: + level, text = len(heading.group(1)), heading.group(2) + if level == 1: + i += 1 + continue + if level == 2: + close_ladders() + if open_section: + out.append("") + match = SECTION_NO.match(text) + if match: + num, title = match.group(1), match.group(2) + anchor = f"s{num}" + rail.append((anchor, num, title)) + out.append( + f'

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

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

{inline(text)}

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

{inline(text)}

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

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

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

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

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

    {inline(text)}

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

    {html.escape(display)}

    " + + (f'

    {html.escape(args.subtitle)}

    ' if args.subtitle else "") + + '
    ' + f'' + f"
    {body}" + f'" + "
    \n" + ) + args.output.write_text(page) + print(f"{args.output}: {len(rail)} sections, {len(page)} bytes") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/style.css b/tools/style.css new file mode 100644 index 0000000..ebf009d --- /dev/null +++ b/tools/style.css @@ -0,0 +1,185 @@ +:root{ + --paper:#EDEEF0; --surface:#F6F7F8; --surface-2:#E4E6E9; + --ink:#171D24; --ink-2:#4A5561; --ink-3:#737E8A; + --rule:#D3D7DC; --rule-strong:#B6BCC3; + --brass:#8A6A2E; --brass-soft:#EFE5CD; --brass-line:#C9AE74; + --clay:#8A3A2C; --clay-soft:#F2DFDA; + --l0:#DCE0E2; --l1:#B9C4C7; --l2:#8CA1A6; --l3:#567D84; --l4:#23555E; + --chip-fg:#F6F7F8; + --font-display:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; + --font-body:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; + --font-mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace; + --measure:66ch; +} +@media (prefers-color-scheme:dark){ + :root:not([data-theme="light"]){ + --paper:#12161A; --surface:#191E24; --surface-2:#222831; + --ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E; + --rule:#2A3138; --rule-strong:#3B444D; + --brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426; + --clay:#D08A76; --clay-soft:#3A211B; + --l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD; + --chip-fg:#12161A; + } +} +:root[data-theme="dark"]{ + --paper:#12161A; --surface:#191E24; --surface-2:#222831; + --ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E; + --rule:#2A3138; --rule-strong:#3B444D; + --brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426; + --clay:#D08A76; --clay-soft:#3A211B; + --l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD; + --chip-fg:#12161A; +} + +*{box-sizing:border-box} +body{ + margin:0; background:var(--paper); color:var(--ink); + font-family:var(--font-body); font-size:17px; line-height:1.62; + -webkit-font-smoothing:antialiased; +} +.wrap{max-width:1180px;margin:0 auto;padding:0 24px 96px} +.layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:56px;align-items:start} +@media (max-width:960px){.layout{grid-template-columns:1fr;gap:0}.rail{display:none}} + +/* ---------- rail ---------- */ +.rail{position:sticky;top:28px;padding-top:8px;font-family:var(--font-display);font-size:12px;line-height:1.5} +.rail ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px} +.rail a{color:var(--ink-3);text-decoration:none;display:flex;gap:9px} +.rail a:hover,.rail a:focus-visible{color:var(--brass)} +.rail .n{font-family:var(--font-mono);font-size:10px;color:var(--rule-strong);min-width:16px;padding-top:1px} +.rail .grp{margin-top:14px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--rule-strong)} + +/* ---------- header ---------- */ +header{padding:64px 0 40px;border-bottom:2px solid var(--ink);margin-bottom:44px} +.eyebrow{font-family:var(--font-mono);font-size:11.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:14px;margin-bottom:22px} +.eyebrow .stat{color:var(--clay)} +h1{font-family:var(--font-display);font-weight:800;letter-spacing:-.035em;line-height:.94;font-size:clamp(46px,9vw,92px);margin:0 0 6px;text-wrap:balance} +.sub{font-family:var(--font-display);font-weight:500;font-size:clamp(16px,2.4vw,21px);letter-spacing:-.01em;color:var(--ink-2);margin:0 0 30px;max-width:34ch;line-height:1.3} +.metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:20px 28px;border-top:1px solid var(--rule);padding-top:20px} +.metagrid dt{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px} +.metagrid dd{margin:0;font-family:var(--font-display);font-size:13.5px;line-height:1.45;color:var(--ink)} + +/* ---------- typography ---------- */ +section{margin-bottom:60px;scroll-margin-top:24px} +h2{font-family:var(--font-display);font-weight:750;letter-spacing:-.022em;font-size:clamp(24px,3.4vw,31px);line-height:1.12;margin:0 0 18px;text-wrap:balance;display:flex;gap:14px;align-items:baseline} +h2 .sn{font-family:var(--font-mono);font-size:12px;font-weight:400;color:var(--brass);letter-spacing:.06em;flex:none;padding-top:2px} +h3{font-family:var(--font-display);font-weight:700;font-size:16px;letter-spacing:-.008em;margin:34px 0 10px;color:var(--ink)} +p{margin:0 0 15px;max-width:var(--measure)} +ul,ol{max-width:var(--measure);margin:0 0 15px;padding-left:20px} +li{margin-bottom:7px} +strong{font-weight:600} +em{font-style:italic} +code{font-family:var(--font-mono);font-size:.855em;background:var(--surface-2);padding:1px 5px;border-radius:2px} +a{color:var(--brass)} +.lede{font-size:19px;line-height:1.55;color:var(--ink-2);max-width:60ch} + +/* ---------- devices ---------- */ +.callout{border-left:3px solid var(--brass);background:var(--brass-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)} +.callout p:last-child{margin-bottom:0} +.callout .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px} +.rule-quote{border-top:2px solid var(--ink);border-bottom:2px solid var(--ink);padding:26px 0;margin:28px 0;max-width:var(--measure)} +.rule-quote p{font-family:var(--font-display);font-weight:600;font-size:19px;line-height:1.38;letter-spacing:-.014em;margin:0;text-wrap:balance} +.hard{border-left:3px solid var(--clay);background:var(--clay-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)} +.hard .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--clay);display:block;margin-bottom:8px} +.hard p:last-child{margin-bottom:0} +.dec{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.08em;color:var(--brass);text-transform:uppercase} +.vec{font-family:var(--font-mono);font-size:.9em;font-weight:600;background:var(--surface-2);padding:2px 7px;border-radius:2px;white-space:nowrap;letter-spacing:.04em} + +/* ---------- tables ---------- */ +.scroll{overflow-x:auto;margin:0 0 24px;-webkit-overflow-scrolling:touch} +table{border-collapse:collapse;width:100%;min-width:520px;font-family:var(--font-display);font-size:13.5px;line-height:1.45} +th{text-align:left;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);font-weight:400;padding:0 16px 8px 0;border-bottom:1px solid var(--rule-strong);vertical-align:bottom} +td{padding:11px 16px 11px 0;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink-2)} +td:first-child{color:var(--ink);font-weight:600} +tbody tr:last-child td{border-bottom:none} +.lvl{font-family:var(--font-mono);font-weight:600;font-size:12px;letter-spacing:.04em;color:var(--ink)} + +/* ---------- ladders ---------- */ +.breakout{margin:34px 0 40px} +.bhead{display:flex;justify-content:space-between;align-items:baseline;gap:20px;border-bottom:1px solid var(--rule-strong);padding-bottom:9px;margin-bottom:22px;flex-wrap:wrap} +.bhead h3{margin:0;font-size:13px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--font-mono);font-weight:400;color:var(--ink-3)} +.bhead .note{font-family:var(--font-display);font-size:12.5px;color:var(--ink-3)} +.ladders{display:grid;gap:26px} +.ladder{display:grid;grid-template-columns:126px minmax(0,1fr);gap:18px;align-items:start} +@media (max-width:700px){.ladder{grid-template-columns:1fr;gap:10px}} +.ladder .pname{font-family:var(--font-display);font-weight:700;font-size:14px;letter-spacing:-.01em;padding-top:2px} +.ladder .pname span{display:block;font-family:var(--font-mono);font-size:10px;font-weight:400;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-top:3px} +.rungs{display:grid;gap:3px;grid-template-columns:repeat(5,minmax(0,1fr))} +@media (max-width:700px){.rungs{grid-template-columns:repeat(2,minmax(0,1fr))}} +.rung{padding:9px 10px 11px;background:var(--surface);border-top:4px solid var(--l0);min-width:0} +.rung.r1{border-top-color:var(--l1)} .rung.r2{border-top-color:var(--l2)} +.rung.r3{border-top-color:var(--l3)} .rung.r4{border-top-color:var(--l4)} +.rung .code{font-family:var(--font-mono);font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ink);display:block;margin-bottom:4px} +.rung .txt{font-family:var(--font-display);font-size:11.5px;line-height:1.34;color:var(--ink-2);display:block} +.rung.na{opacity:.42} + +/* ---------- matrix ---------- */ +.matrix-shell{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:stretch;margin-bottom:14px} +.ylab{writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);text-align:center;padding-bottom:22px} +.mgrid{display:grid;grid-template-columns:34px repeat(5,minmax(0,1fr));gap:3px} +.mcell{background:var(--surface);min-height:60px;padding:6px;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;min-width:0} +.mcell.tint1{background:color-mix(in srgb,var(--l1) 26%,var(--surface))} +.mcell.tint2{background:color-mix(in srgb,var(--l2) 26%,var(--surface))} +.mcell.tint3{background:color-mix(in srgb,var(--l3) 24%,var(--surface))} +.mcell.tint4{background:color-mix(in srgb,var(--l4) 22%,var(--surface))} +.mcell.void{background:repeating-linear-gradient(135deg,transparent,transparent 5px,var(--rule) 5px,var(--rule) 6px);opacity:.55} +.rlab,.clab{font-family:var(--font-mono);font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--ink-3);display:flex;align-items:center;justify-content:center} +.rlab{min-height:60px} +.clab{padding-top:7px;min-height:22px} +.pin{font-family:var(--font-mono);font-size:9.5px;font-weight:600;letter-spacing:.02em;background:var(--ink);color:var(--paper);padding:2px 5px;border-radius:2px;line-height:1.3;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.pin.ghost{background:transparent;color:var(--ink-2);border:1px dashed var(--rule-strong)} +.mnote{display:flex;gap:22px;flex-wrap:wrap;font-family:var(--font-display);font-size:12px;color:var(--ink-3);padding-top:6px} +.mnote .k{display:flex;align-items:center;gap:7px} +.sw{width:13px;height:13px;flex:none;background:var(--ink)} +.sw.g{background:transparent;border:1px dashed var(--rule-strong)} +.sw.v{background:repeating-linear-gradient(135deg,transparent,transparent 4px,var(--rule) 4px,var(--rule) 5px);border:1px solid var(--rule)} +@media (max-width:640px){ + .mgrid{grid-template-columns:28px repeat(5,minmax(0,1fr))} + .mcell{min-height:52px;padding:4px} + .pin{font-size:8px;padding:1px 3px} + .rlab{min-height:52px} +} + +/* ---------- methodology ---------- */ +.verbs{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule)} +.verb{background:var(--surface);padding:18px 18px 20px} +.verb h4{font-family:var(--font-display);font-weight:750;font-size:15px;margin:0 0 7px;letter-spacing:-.01em} +.verb p{font-family:var(--font-display);font-size:12.5px;line-height:1.46;color:var(--ink-2);margin:0;max-width:none} +.verb .step{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;color:var(--brass);display:block;margin-bottom:9px} + +/* ---------- questions ---------- */ +.qs{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--rule-strong)} +.q{display:grid;grid-template-columns:34px minmax(0,1fr) 170px;gap:18px;padding:16px 0;border-bottom:1px solid var(--rule);align-items:start} +@media (max-width:760px){.q{grid-template-columns:28px minmax(0,1fr);gap:12px}.q .owner{grid-column:2}} +.q .qn{font-family:var(--font-mono);font-size:11px;color:var(--brass);padding-top:3px} +.q .qt{font-family:var(--font-display);font-size:14px;line-height:1.48;color:var(--ink-2)} +.q .qt b{color:var(--ink);font-weight:700;display:block;margin-bottom:2px;font-size:14.5px} +.owner{font-family:var(--font-mono);font-size:10px;letter-spacing:.05em;color:var(--ink-3);padding-top:4px} +.owner .tag{display:inline-block;border:1px solid var(--rule-strong);padding:2px 7px;border-radius:2px} +.owner .tag.need{border-color:var(--clay);color:var(--clay)} + +/* ---------- misc ---------- */ +.numbers{font-family:var(--font-mono);font-size:12.5px;line-height:1.85;background:var(--surface);border-left:3px solid var(--l3);padding:16px 20px;margin:0 0 22px;overflow-x:auto;max-width:var(--measure)} +.numbers .v{color:var(--ink);font-weight:600} +.numbers .k{color:var(--ink-3)} +pre{font-family:var(--font-mono);font-size:12.5px;line-height:1.68;background:var(--surface);border-left:3px solid var(--rule-strong);padding:16px 20px;overflow-x:auto;margin:0 0 22px;max-width:var(--measure);color:var(--ink-2)} +.alt{border-bottom:1px solid var(--rule);padding:14px 0;max-width:var(--measure)} +.alt:last-of-type{border-bottom:none} +.alt b{font-family:var(--font-display);font-size:14px;display:block;margin-bottom:3px} +.alt p{font-size:14.5px;margin:0;color:var(--ink-2)} +.alt .verdict{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--clay)} +footer{border-top:2px solid var(--ink);margin-top:20px;padding-top:22px;font-family:var(--font-mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap} +.tm td,.tm th{text-align:center} +.tm td:first-child,.tm th:first-child{text-align:left} +.yes{color:var(--l4);font-weight:700} +.no{color:var(--clay);font-weight:700} +.kind{font-family:var(--font-mono);font-size:9px;letter-spacing:.09em;text-transform:uppercase;padding:2px 6px;border-radius:2px;white-space:nowrap;border:1px solid var(--rule-strong);color:var(--ink-3)} +.kind.adv{border-color:var(--clay);color:var(--clay)} +.routes{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule);margin:0 0 22px} +.route{background:var(--surface);padding:16px 18px} +.route h4{font-family:var(--font-display);font-weight:750;font-size:14px;margin:0 0 6px} +.route p{font-family:var(--font-display);font-size:12.5px;line-height:1.45;color:var(--ink-2);margin:0;max-width:none} +.route .tag{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px} +a:focus-visible,.rail a:focus-visible{outline:2px solid var(--brass);outline-offset:3px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/workplans/POLICY-NEXUS-WP-0001-permanent-publication-surface.md b/workplans/POLICY-NEXUS-WP-0001-permanent-publication-surface.md index 09c597a..1fe8089 100644 --- a/workplans/POLICY-NEXUS-WP-0001-permanent-publication-surface.md +++ b/workplans/POLICY-NEXUS-WP-0001-permanent-publication-surface.md @@ -25,7 +25,8 @@ superseded versions, and can be reached by someone outside the estate. ## The forcing case -Custodian ADR-008 (*Tenancy Posture*, draft-4) needs review from six repos — +`net-kingdom/canon/standards/tenancy-posture_v0.1.md` (*Tenancy Posture*, +draft-5) needs review from six repos — `tenant-engine`, `flex-auth`, `rapp-postgres`, `railiance-platform`, `adaptive-pricing`, `audit-core`. It is currently served from a private, disposable artifact URL. Routing a document that governs six repos to a link @@ -37,13 +38,12 @@ owner-attributed open questions — the site is not finished. ## Existing structure this workplan must respect -**The renderer already exists in prototype.** -`the-custodian/tools/render-artifact.py` generates the ADR-008 page from canon -markdown: stdlib only, no dependency tree, recognising conventions already -present in the document rather than requiring extra markup. It was written -because the page and the ADR had diverged. **Take it over and generalise it; do -not reimplement it.** Its companion `tools/artifact-style.css` carries the -design system. +**The renderer is already here.** `tools/render.py` and `tools/style.css` +arrived from `the-custodian` on 2026-08-17, along with `make build`. Stdlib +only, no dependency tree, recognising conventions already present in the +document rather than requiring extra markup. It was written because the page +and the source had diverged. T02 generalises it from one document to many; it +does not start from scratch. **Source of truth stays upstream.** Canon lives in `the-custodian/canon`; per-repo ADRs live in their own repos. This repo reads and never writes back. @@ -249,15 +249,38 @@ not attract the wrong contributions. 1. **Owner.** This workplan is `unassigned`. It spans infrastructure and canon process and does not obviously belong to an existing repo's agent. -2. **Public by default?** INTENT assumes everything on this surface is - public-by-intent. Confirm that no estate policy is sensitive enough to need - an authenticated tier — if any is, that changes T04 substantially. Now - sharper than when first asked: publishing every ADR across 18 repositories - exposes the estate's architecture, its known gaps and its residual risks in - one indexed place. `rapp-postgres` ADR-0001 §5 publishes a blast radius by - design, and that is the right call for a document its consumers must read — - but the same disclosure discipline applied estate-wide, publicly, is a - decision worth taking deliberately rather than inheriting from a default. -3. **Canon subdirectory scope.** `standards` and `architecture` are clearly +2. **Canon subdirectory scope.** `standards` and `architecture` are clearly policy. Are `constitution`, `values`, `tpsc` and `projects` in or out? T03 needs a yes or no per directory rather than a wildcard. + +## Deferred: controlled disclosure + +**Resolved for now (operator, 2026-08-17): full public disclosure is fine.** The +estate is in build mode, not production. Publishing architecture, known gaps and +residual risks openly costs little while there are no users to expose and no +attacker with anything to gain. + +**That stops being true at production.** ADR-0001 §5 publishes a blast radius +because its consumers must read it; the same discipline applied publicly, once +real tenant data exists, hands an attacker a map. The estate will then need a +**controlled-disclosure scheme**: a way to hold a finding while it is fixed, and +publish it once it is — embargo, coordinated timing, and a record that the delay +was deliberate rather than a document quietly going missing. + +This workplan does **not** build that, and should not. Two reasons: + +1. It is a different problem. Publication is about permanence and currency; + embargo is about risk assessment, severity and timing. Building embargo into + a publication surface would put risk judgement in the repo least qualified to + make it. +2. It likely belongs to a service of its own — a **`risk-nexus`**, by analogy — + owning finding intake, severity, remediation tracking and disclosure timing, + with this repo as its publication surface rather than its brain. + +**What T01 must do about it now:** nothing more than leave room. The addressing +scheme should not assume every document is public from the moment it exists, so +that adding an embargo state later is a new status rather than a URL migration. +Recording that constraint costs nothing today and is expensive to retrofit. + +**Trigger to revisit:** the first real tenant, or the first finding that would +be dangerous to publish before it is fixed — whichever comes first.