Compare commits
No commits in common. "9a6e14e73381c9cf08d9f7ca6739882bcbf79594" and "f8038ecde561b9eb7087cc4ac558e38f7779079d" have entirely different histories.
9a6e14e733
...
f8038ecde5
3 changed files with 273 additions and 626 deletions
|
|
@ -82,13 +82,6 @@ nothing prevents number reuse.
|
||||||
**1. A workplan identifier is globally unique.** `PREFIX-WP-NNNN` names exactly
|
**1. A workplan identifier is globally unique.** `PREFIX-WP-NNNN` names exactly
|
||||||
one workplan across the entire fleet, for all time.
|
one workplan across the entire fleet, for all time.
|
||||||
|
|
||||||
> **Amended 2026-08-17 by `ADR-009` decision 2.** Uniqueness and forward-only
|
|
||||||
> allocation are **namespace-scoped**, not global; global identity is the pair
|
|
||||||
> `(namespace, identifier)`, written `PREFIX-WP-NNNN@namespace` when foreign.
|
|
||||||
> Global sequential allocation would require a central coordinator — the exact
|
|
||||||
> dependency federation must survive. Everything below holds unchanged **within**
|
|
||||||
> a namespace, which is where all current work sits.
|
|
||||||
|
|
||||||
- A workplan prefix is owned by exactly one repository. No two repositories may
|
- A workplan prefix is owned by exactly one repository. No two repositories may
|
||||||
use the same prefix.
|
use the same prefix.
|
||||||
- A running number is never reused within a prefix, including after a workplan is
|
- A running number is never reused within a prefix, including after a workplan is
|
||||||
|
|
@ -103,10 +96,8 @@ It also inverts ADR-001 — a file carrying a hub's private key is the file
|
||||||
holding hub state.
|
holding hub state.
|
||||||
|
|
||||||
*Target state (C2).* `state_hub_workstream_id` and `state_hub_task_id` become
|
*Target state (C2).* `state_hub_workstream_id` and `state_hub_task_id` become
|
||||||
**deterministic**: UUIDv5 derived from the workplan identifier. Per `ADR-009`
|
**deterministic**: UUIDv5 derived from the globally unique `PREFIX-WP-NNNN`
|
||||||
decision 3 the derivation input is the pair `(namespace, identifier)`, not the
|
identifier. Every instance computes the same value independently, writeback
|
||||||
identifier alone — deriving from the identifier alone would make two forks
|
|
||||||
holding unrelated work under the same number compute the same UUID. Every instance computes the same value independently, writeback
|
|
||||||
becomes idempotent, and any number of hub instances may coexist without
|
becomes idempotent, and any number of hub instances may coexist without
|
||||||
coordination. The field shape is unchanged, so consumers keep working; only the
|
coordination. The field shape is unchanged, so consumers keep working; only the
|
||||||
provenance of the value changes.
|
provenance of the value changes.
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,25 @@
|
||||||
---
|
---
|
||||||
id: ADR-008
|
id: ADR-008
|
||||||
type: architecture-decision-record
|
type: architecture-decision-record
|
||||||
title: "Multi-Tenancy Framework: Four Planes, Graduated Levels, Declared Posture"
|
title: "Multi-Tenancy Model: Four Planes, One Isolation Ladder"
|
||||||
status: proposed
|
status: proposed
|
||||||
decided_by: Bernd Worsch
|
decided_by: Bernd Worsch
|
||||||
date: "2026-08-17"
|
date: "2026-08-17"
|
||||||
revision: "draft-2"
|
tags: ["architecture", "multi-tenancy", "isolation", "placement", "tenant-engine", "flex-auth", "rapp-postgres", "scaling"]
|
||||||
tags: ["architecture", "multi-tenancy", "isolation", "placement", "maturity", "tenant-engine", "flex-auth", "rapp-postgres", "scaling"]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# ADR-008: Multi-Tenancy Framework — Four Planes, Graduated Levels, Declared Posture
|
# ADR-008: Multi-Tenancy Model — Four Planes, One Isolation Ladder
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Proposed, draft-2.** Draft-1 (2026-08-17) proposed a single model with fixed
|
**Proposed.** Drafted 2026-08-17 at the operator's request. This document
|
||||||
characteristics. It was rejected on the grounds that the estate does not need
|
decides things currently owned by `tenant-engine`, `flex-auth`,
|
||||||
one model of multi-tenancy — it needs a framework able to describe, hold, and
|
|
||||||
improve several situations at once, including repos that do not separate
|
|
||||||
tenants rigorously today and must be developed toward doing so. Draft-2 is
|
|
||||||
that reframe.
|
|
||||||
|
|
||||||
This document decides things currently owned by `tenant-engine`, `flex-auth`,
|
|
||||||
`rapp-postgres`, `adaptive-pricing`, and `railiance-platform`. It must be
|
`rapp-postgres`, `adaptive-pricing`, and `railiance-platform`. It must be
|
||||||
reviewed by those owners before ratification; §18 lists what each is being
|
reviewed by those owners before ratification; §12 lists what each is being
|
||||||
asked to accept.
|
asked to accept. Until ratified it governs nothing, and the existing
|
||||||
|
per-repo documents remain authoritative for their own repos.
|
||||||
|
|
||||||
## 1. Context
|
## Context
|
||||||
|
|
||||||
The estate has been building multi-tenancy for months and has never written
|
The estate has been building multi-tenancy for months and has never written
|
||||||
down what it is building. Five documents each cover a slice:
|
down what it is building. Five documents each cover a slice:
|
||||||
|
|
@ -38,432 +32,260 @@ down what it is building. Five documents each cover a slice:
|
||||||
| `rapp-postgres/docs/adr/ADR-0001` | Consumer + tenant isolation in PostgreSQL | Proposed, governs one repo |
|
| `rapp-postgres/docs/adr/ADR-0001` | Consumer + tenant isolation in PostgreSQL | Proposed, governs one repo |
|
||||||
| `rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1` | The stacked-boundary gap | Routed 2026-08-10, **still unratified** |
|
| `rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1` | The stacked-boundary gap | Routed 2026-08-10, **still unratified** |
|
||||||
|
|
||||||
Four failures follow.
|
The slices do not compose, and three specific failures follow.
|
||||||
|
|
||||||
**The gap has been diagnosed once and the fix stalled.** The
|
**The gap has been diagnosed once and the fix stalled.** The
|
||||||
shared-platform-relational-storage draft was written to fill this hole. It has
|
shared-platform-relational-storage draft was written precisely to fill this
|
||||||
sat unratified for a week and appears in neither canon directory. §19 attaches
|
hole. It has sat unratified for a week and appears in neither canon
|
||||||
a ratification path so this one does not join it.
|
directory. Producing a second orphaned draft would make things worse, not
|
||||||
|
better; §13 therefore attaches a ratification path to this one.
|
||||||
|
|
||||||
**Placement is owned by nobody.** `user-engine-pg` and `target-revenue-pg` are
|
**Placement is owned by nobody.** Whether a workload gets a dedicated
|
||||||
dedicated; `apps-pg`, `net-kingdom-pg`, `platform-pg`, `state-hub-db` and
|
PostgreSQL cluster or a shared one is currently precedent-by-accident:
|
||||||
`forgejo-db` are shared. Both patterns are live, neither is written down, and
|
`user-engine-pg` and `target-revenue-pg` are dedicated; `apps-pg`,
|
||||||
each new service copies whichever neighbour it consulted. `tenant-engine`
|
`net-kingdom-pg`, `platform-pg`, `state-hub-db` and `forgejo-db` are shared.
|
||||||
raised this with `railiance-platform` on 2026-08-16; unanswered.
|
Both patterns are live, neither is written down, and each new service copies
|
||||||
|
whichever neighbour it happened to consult. `tenant-engine` raised this with
|
||||||
|
`railiance-platform` on 2026-08-16 and it is unanswered.
|
||||||
|
|
||||||
**Two contradictory tenancy defaults are already ratified.**
|
**Two contradictory tenancy defaults are already ratified.**
|
||||||
`business-app-service-contract` §1.2 makes instance-per-client the default for
|
`business-app-service-contract` §1.2 makes *instance-per-client* the default
|
||||||
business apps. Platform services pool all tenants into one database. Nothing
|
for business apps. Platform services such as `audit-core` and `tenant-engine`
|
||||||
states which shape a new service takes, and no definition distinguishes the
|
pool all tenants into one database. Both are correct for their case, but
|
||||||
two categories.
|
nothing states which shape a new service takes, and the estate has no
|
||||||
|
definition distinguishing "business app" from "platform service". A new
|
||||||
|
repo can read either document and be conformant with one while violating the
|
||||||
|
other.
|
||||||
|
|
||||||
**There is no honest way to describe a repo that is not there yet.** This is
|
## Decision
|
||||||
the failure draft-1 missed. The estate absorbs and will keep absorbing repos
|
|
||||||
with weak or absent tenant separation — app-local accounts, no tenant column,
|
|
||||||
ad-hoc filtering. Today such a repo is simply non-conformant, which gives it
|
|
||||||
two bad options: misrepresent its posture, or stay outside the framework
|
|
||||||
entirely. Both are worse than a framework that can say "this repo is at level
|
|
||||||
1, its target is level 3, here is the gap and the evidence."
|
|
||||||
|
|
||||||
## 2. What this document is
|
### 1. Multi-tenancy is four orthogonal planes, not one property
|
||||||
|
|
||||||
**A framework, not a model.** It does not specify one correct multi-tenancy
|
The recurring confusion is that "is this multi-tenant?" is treated as a
|
||||||
implementation with a fixed set of characteristics. It supplies:
|
single question. It is four, and they are independent:
|
||||||
|
|
||||||
- **Terminology** — four planes, and graduated levels within each (§3, §4).
|
| Plane | Question | Owner | State |
|
||||||
- **A declaration** — the posture vector, so any repo can state where it
|
|---|---|---|---|
|
||||||
actually is (§5).
|
| **Identity** | How is a tenant named and validated? | `tenant-engine` / IAM Profile | Ratified, solid |
|
||||||
- **A conformance rule** — accuracy, not altitude (§6).
|
| **Authorization** | How is a request bound to the tenants it may act for? | `flex-auth` | Contract ratified |
|
||||||
- **Methodology** — analyze, establish, improve, guard (§12).
|
| **Data isolation** | Where does the boundary between two tenants' rows sit? | The consuming service | Stated in ADR-0001, unverified fleet-wide |
|
||||||
- **Evidence definitions** — what proves a claimed level (§13).
|
| **Placement** | Which physical substrate holds a tenant's data? | *unowned* | Precedent only |
|
||||||
|
|
||||||
A service is conformant when its declared posture is accurate and its
|
Treating them as one is not a theoretical tidiness problem; it is producing
|
||||||
trajectory is recorded. A service is non-conformant when it claims a level it
|
concrete errors today.
|
||||||
cannot evidence — regardless of how high or low that level is.
|
|
||||||
|
|
||||||
## 3. Four orthogonal planes
|
`rapp-postgres`'s `PostgresConsumer` declaration carries a field
|
||||||
|
`tenantIsolation: consumer-service-boundary`. That is a **data-isolation**
|
||||||
|
fact recorded in a **placement** artifact. It reads as though the storage
|
||||||
|
platform enforces something. It does not, and ADR-0001 §4 is explicit that it
|
||||||
|
must not pretend to.
|
||||||
|
|
||||||
"Is this multi-tenant?" is treated as one question. It is four, and they are
|
The "dedicated versus shared" argument routinely mixes placement (a capacity
|
||||||
independent:
|
and blast-radius decision) with data isolation (a correctness decision).
|
||||||
|
`tenant-engine` drew the right distinction unprompted on 2026-08-16 —
|
||||||
|
sharing a *cluster* is capacity and cheap to undo; sharing a *database*
|
||||||
|
entangles schemas and is not — and that distinction is adopted here as
|
||||||
|
binding.
|
||||||
|
|
||||||
| Plane | Question | Vocabulary owner |
|
**Decision:** every document, declaration, and plan tier that says
|
||||||
|---|---|---|
|
|
||||||
| **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 physical substrate holds a tenant's data? | `railiance-platform` |
|
|
||||||
|
|
||||||
Treating them as one produces concrete errors today. `rapp-postgres`'s
|
|
||||||
`PostgresConsumer` carries a field `tenantIsolation: consumer-service-boundary`
|
|
||||||
— an **E**-plane fact recorded in a **P**-plane artifact, reading as though the
|
|
||||||
storage platform enforces something it does not. The "dedicated versus shared"
|
|
||||||
argument routinely mixes P (capacity, blast radius) with E (correctness).
|
|
||||||
|
|
||||||
The planes are separated **precisely so that each may sit at a different
|
|
||||||
level**. A repo can be strong on identity and weak on enforcement, or the
|
|
||||||
reverse, and the framework must state that without flattening it to a single
|
|
||||||
verdict. That is the whole point, and draft-1 lost it by collapsing E into a
|
|
||||||
single fixed rule.
|
|
||||||
|
|
||||||
**Decision 3.1:** every document, declaration, and plan tier that says
|
|
||||||
"isolation" MUST name which plane it means.
|
"isolation" MUST name which plane it means.
|
||||||
|
|
||||||
**Decision 3.2:** the planes couple at their top levels — `E4` is only
|
### 2. The identity and authorization planes are settled; this ADR does not reopen them
|
||||||
reachable at `P3` or above — and that coupling MUST be stated where it applies
|
|
||||||
rather than used to argue the planes are one.
|
|
||||||
|
|
||||||
## 4. Graduated levels
|
Recorded here only so the model is complete and so no future document
|
||||||
|
re-derives them:
|
||||||
|
|
||||||
Each plane carries an ordered ladder. Higher is stronger, not better: the
|
- Tenant identifiers are `tenant:<grouping>:<name>` per ADR-0013, with
|
||||||
right level is the one a service can evidence and its risk warrants.
|
`tenant:platform` and `tenant:coulomb` reserved and ungrouped. `tenant-engine`
|
||||||
|
owns which values exist; the IAM Profile owns the wire format.
|
||||||
|
- Capability roles `PLTF`/`IAM`/`VEN`/`CUS` are non-exclusive and are a
|
||||||
|
separate fact from grouping. `tenant-engine` is the source of truth; the
|
||||||
|
`tenant_roles` token claim is a point-in-time cache that MUST NOT be trusted
|
||||||
|
for privileged, destructive, credential-vending, or `aal2`-class decisions.
|
||||||
|
- `flex-auth` makes authorization decisions; `tenant-engine` is a data source
|
||||||
|
and makes none.
|
||||||
|
- Storage platforms MUST NOT mint, parse, or authorize on tenant identifiers.
|
||||||
|
To them these are opaque strings.
|
||||||
|
|
||||||
### 4.1 Identity (I) — how the tenant is named
|
### 3. Data isolation is enforced by the consuming service, and the platform must never imply otherwise
|
||||||
|
|
||||||
| Level | State |
|
This is ADR-0001 §4 promoted to fleet scope.
|
||||||
|---|---|
|
|
||||||
| **I0** | No tenant concept. Data is not attributable to a tenant. |
|
|
||||||
| **I1** | A local tenant notion exists but does not conform to `tenant:<grouping>:<name>`. |
|
|
||||||
| **I2** | Canonical identifiers used, validated against the ratified vocabulary; `tenant-engine` is the source of existence. |
|
|
||||||
| **I3** | I2 plus capability roles (`PLTF`/`IAM`/`VEN`/`CUS`) honoured, with live `tenant-engine` re-query for privileged, destructive, credential-vending or `aal2`-class decisions rather than trusting the cached `tenant_roles` claim. |
|
|
||||||
|
|
||||||
Note `business-app-service-contract` §2.1 sets app-local accounts as the v1
|
A consumer connects through a pooled role shared across all of its tenants.
|
||||||
baseline for business apps — an explicitly sanctioned low level with recorded
|
The database therefore sees one principal, not the requesting tenant, and has
|
||||||
triggers for moving up. That is the pattern this framework generalises.
|
no trustworthy basis on which to filter. Any database-level tenant filter
|
||||||
|
would depend on the consumer correctly setting a session variable — which is
|
||||||
|
the application-level check the consumer already owes, relocated somewhere it
|
||||||
|
*looks* like a database guarantee without being one.
|
||||||
|
|
||||||
### 4.2 Authorization (A) — how a request is bound to a tenant
|
- **Mandatory:** consumer-owned data is tenant-keyed from the first
|
||||||
|
migration, even where the consumer serves exactly one tenant today.
|
||||||
|
- **Mandatory:** the consumer binds the authenticated identity to the tenants
|
||||||
|
it may act for, at its own service boundary.
|
||||||
|
- **Available, opt-in:** row-level security keyed on a session GUC set at pool
|
||||||
|
checkout, as defence in depth behind a correct service-side check — never
|
||||||
|
as a substitute for one.
|
||||||
|
- **Prohibited:** describing the opt-in layer as a platform guarantee.
|
||||||
|
|
||||||
| Level | State |
|
**This is the model's largest residual risk and §9 does not soften it.**
|
||||||
|---|---|
|
|
||||||
| **A0** | No authorization, or tenant context not carried on the request. |
|
|
||||||
| **A1** | Ad-hoc checks scattered through handlers. |
|
|
||||||
| **A2** | A single local authorization boundary; tenant context bound once, centrally. |
|
|
||||||
| **A3** | Decisions delegated to `flex-auth` as PDP, with live re-query where the IAM Profile requires it. |
|
|
||||||
|
|
||||||
### 4.3 Enforcement (E) — where the tenant boundary actually holds
|
### 4. The isolation ladder
|
||||||
|
|
||||||
This is the reframed core. Draft-1 asserted one rule for everyone; this is a
|
Placement decisions are expressed as rungs. Each rung states what it
|
||||||
ladder whose top is platform-enforced.
|
actually buys, and — critically — what it does *not* change.
|
||||||
|
|
||||||
| Level | Mechanism | Bypassed by |
|
| Rung | Shape | Data-isolation guarantee | Blast radius of a leaked runtime credential | Live occupants |
|
||||||
|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **E0** | None. Data not tenant-keyed; separation is incidental or absent. | Anything. |
|
| **R0** | Shared tables, tenant column | App-enforced | All tenants of that consumer | Inside every consumer today |
|
||||||
| **E1** | Data tenant-keyed, filtering applied per query at call sites. | One missing predicate. |
|
| **R1** | Database per consumer, shared cluster | App-enforced *(unchanged)* | All tenants of that consumer | `audit-core`, `tenant-engine` on `platform-pg` |
|
||||||
| **E2** | Filtering centralised at a single service-side choke point that binds authenticated identity to permitted tenants. | Code that bypasses the choke point. |
|
| **R2** | Dedicated cluster per consumer | App-enforced *(unchanged)* | All tenants of that consumer | `user-engine-pg`, `target-revenue-pg` |
|
||||||
| **E3** | Platform-assisted: row-level security keyed on a session GUC set at pool checkout, or an equivalent data-access layer the application cannot trivially route around. | A `SECURITY DEFINER` function, a missing policy, or a wrong GUC. |
|
| **R3** | Dedicated cluster per tenant | Structural | One tenant | Business apps per `business-app-service-contract` §1.2 |
|
||||||
| **E4** | Structural: the connection a workload holds cannot address another tenant's data at all. Requires per-tenant credentials and per-tenant substrate. | Only a platform-level compromise. |
|
| **R4** | R3 + separate region/jurisdiction | Structural + residency | One tenant | None |
|
||||||
|
|
||||||
Three properties of this ladder matter.
|
The column that matters is the third one. **R0 → R1 → R2 does not improve
|
||||||
|
tenant isolation at all.** Those rungs buy consumer isolation, capacity
|
||||||
|
predictability, independent backup retention, and a smaller operational blast
|
||||||
|
radius. The tenant boundary stays exactly where §3 puts it: in application
|
||||||
|
code. Only R3 makes it structural.
|
||||||
|
|
||||||
**E3 is real and is not forbidden.** Draft-1 rejected RLS on the grounds that
|
**Decision:** R1 is the default for platform services. R3 is the default for
|
||||||
a control depending on the consumer setting a session variable "looks like a
|
client-facing business apps, as already ratified. A service that is unsure
|
||||||
database guarantee without being one". The observation is correct; the
|
which it is MUST resolve that before choosing a rung (§12.4).
|
||||||
conclusion was wrong. E3 is materially stronger than E2 — it converts "every
|
|
||||||
call site must remember" into "one checkout path must be right" — and it is
|
|
||||||
the standard mitigation in pool-model systems. What was actually wrong was
|
|
||||||
*calling E3 by E4's name*. The honesty requirement survives; the prohibition
|
|
||||||
does not.
|
|
||||||
|
|
||||||
**Higher enforcement is the direction of travel, not an optional extra.**
|
### 5. Movement between rungs must be an operational change, not a rebuild
|
||||||
Services holding data whose cross-tenant exposure would be a reportable breach
|
|
||||||
SHOULD target E3 or above. The framework's default expectation for a new
|
|
||||||
platform service is **E2 at first serve, E3 recorded as target**.
|
|
||||||
|
|
||||||
**Platform enforcement is a platform obligation, not only a consumer one.**
|
Adopted from `tenant-engine`'s TEN-WP-0009 design, which got this right:
|
||||||
Draft-1 placed the whole burden on consumers permanently. It is now stated
|
|
||||||
that reaching E3 requires the storage platform to *offer* the mechanism —
|
|
||||||
provisioned RLS policies, a documented GUC contract, and a probe.
|
|
||||||
`rapp-postgres` ADR-0001 §4 already commits to provisioning it on request;
|
|
||||||
that offer becomes an obligation with a defined contract (§18.6). Where a
|
|
||||||
consumer wants E3 and the platform has not supplied the mechanism, the gap is
|
|
||||||
the platform's, not the consumer's.
|
|
||||||
|
|
||||||
### 4.4 Placement (P) — which substrate holds the data
|
- Connect by injected credential only. No cluster, host, namespace, or
|
||||||
|
database name anywhere in source.
|
||||||
Draft-1's `R0` ("shared tables, tenant column") is removed: that was an
|
|
||||||
E-plane state mislabelled as placement. Corrected ladder:
|
|
||||||
|
|
||||||
| Level | Shape | Live occupants |
|
|
||||||
|---|---|---|
|
|
||||||
| **P0** | Shares a database with another consumer (schema or tables co-resident). | None sanctioned; the state absorbed repos often arrive in. |
|
|
||||||
| **P1** | Database per consumer, shared cluster. | `audit-core`, `tenant-engine` on `platform-pg` |
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
**P0 → P1 → P2 does not raise the E level.** Those steps buy consumer
|
|
||||||
isolation, capacity predictability, independent backup retention, and a
|
|
||||||
smaller operational blast radius. They leave the tenant boundary exactly where
|
|
||||||
the E level puts it. Only P3 makes E4 reachable. This is the single most
|
|
||||||
misusable fact in the framework and §11 governs how it may be described
|
|
||||||
commercially.
|
|
||||||
|
|
||||||
**Decision 4.5:** P1 is the default for platform services; P3 is the default
|
|
||||||
for client-facing business apps, as already ratified. A service unsure which
|
|
||||||
it is must resolve that before choosing (§18.4).
|
|
||||||
|
|
||||||
## 5. The posture vector
|
|
||||||
|
|
||||||
A service states its tenancy posture as one level per plane, plus a target and
|
|
||||||
a date:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
tenancy:
|
|
||||||
current: { I: 2, A: 3, E: 2, P: 1 }
|
|
||||||
target: { I: 2, A: 3, E: 3, P: 1 }
|
|
||||||
reviewed: "2026-08-17"
|
|
||||||
gap:
|
|
||||||
E: "Choke point exists and is tested; RLS not yet provisioned. Blocked on
|
|
||||||
rapp-postgres offering the GUC contract. Target Q4."
|
|
||||||
```
|
|
||||||
|
|
||||||
Worked examples, as best assessed today and subject to owner correction:
|
|
||||||
|
|
||||||
| Service | Current | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `tenant-engine` | `I2 A3 E2 P1` | Moving to P1 under TEN-WP-0009; E2 via its own service boundary. |
|
|
||||||
| `audit-core` | `I2 A3 E2 P1` | Same shape; holds audit data, so E3 is the more urgent target. |
|
|
||||||
| A newly absorbed repo | `I1 A1 E1 P0` | Conformant **if declared**, with a recorded path. |
|
|
||||||
|
|
||||||
The vector is what makes the framework hold both situations honestly. A repo
|
|
||||||
at `I1 A1 E1 P0` is not a failure — it is a described state with a direction.
|
|
||||||
A repo claiming `E3` with no RLS policy is a failure regardless of how good the
|
|
||||||
rest of it is.
|
|
||||||
|
|
||||||
**Decision 5.1:** the posture vector is declared in the repo, not in the hub,
|
|
||||||
consistent with local-files-are-source-of-truth.
|
|
||||||
|
|
||||||
## 6. Honest absorption — conformance is accuracy, not altitude
|
|
||||||
|
|
||||||
The governing rule of this framework:
|
|
||||||
|
|
||||||
> **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.**
|
|
||||||
|
|
||||||
Consequences, stated deliberately:
|
|
||||||
|
|
||||||
- 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 (§11).
|
|
||||||
- Downgrading is permitted and must be declared. A regression discovered by
|
|
||||||
guarding (§12) is a defect; a regression declared in advance is a decision.
|
|
||||||
|
|
||||||
This is what the plane separation is *for*. Without it, "not rigorous about
|
|
||||||
tenant separation" is a single verdict that a repo either passes or fails.
|
|
||||||
With it, the same repo is `I1 A1 E1 P0` with a path — which is a plan, not an
|
|
||||||
indictment.
|
|
||||||
|
|
||||||
## 7. Portability across placement levels
|
|
||||||
|
|
||||||
Movement between P levels must be an operational change, not a rebuild.
|
|
||||||
Adopted from `tenant-engine`'s TEN-WP-0009 design:
|
|
||||||
|
|
||||||
- Connect by injected credential only — no cluster, host, namespace, or
|
|
||||||
database name in source.
|
|
||||||
- Own a whole database, never tables inside someone else's. Sharing a cluster
|
- Own a whole database, never tables inside someone else's. Sharing a cluster
|
||||||
is reversible; sharing a database makes relocation a schema merge.
|
is reversible; sharing a database makes relocation a schema merge.
|
||||||
- Idempotent schema creation.
|
- Idempotent schema creation, so a fresh target comes up correct.
|
||||||
- No cross-database joins or co-location assumptions.
|
- No cross-database joins or co-location assumptions.
|
||||||
|
|
||||||
A service meeting these moves P1 → P2 by: create target, dump, restore, swap
|
A service meeting these moves R1 → R2 by: create target, dump, restore, swap
|
||||||
the credential reference, restart.
|
the credential reference, restart. No rebuild, no release, no code review.
|
||||||
|
|
||||||
**Decision 7.1:** mandatory at P1 and above. At P3, SHOULD rather than MUST —
|
**Decision:** these four properties are mandatory at every rung, including
|
||||||
the consolidation `business-app-service-contract` §1.3 preserves is a real
|
R3. A business app at R3 that hard-codes its database name has forfeited
|
||||||
benefit, but a per-client instance that never moves is not misconformant for
|
consolidation, which §1.3 of the business-app contract exists to preserve.
|
||||||
naming its own database.
|
|
||||||
|
|
||||||
## 8. Placement triggers
|
### 6. Placement triggers are recorded in advance
|
||||||
|
|
||||||
A level change decided under pressure is decided badly. Each service records
|
A rung change decided under pressure is decided badly. Each service records,
|
||||||
at provisioning time what would move it:
|
at provisioning time, what would move it up:
|
||||||
|
|
||||||
- Noisy neighbour on a latency-critical path
|
- Noisy neighbour on a latency-critical path
|
||||||
- A compliance or residency requirement
|
- A compliance or residency requirement
|
||||||
- A plan tier requiring a higher minimum (§11)
|
- A plan tier that sells isolation (see §7)
|
||||||
- Backup retention or RTO no longer fitting
|
- Backup retention or RTO no longer fitting
|
||||||
- Connection or memory ceiling reached (§16)
|
- Connection or memory ceiling reached (see §9)
|
||||||
|
|
||||||
**Decision 8.1:** triggers MUST be *monitored*, not merely recorded. A trigger
|
**Decision:** placement ownership is `railiance-platform`'s. This ADR
|
||||||
in a YAML comment that nobody re-reads is documentation, not control. The
|
proposes the ladder and the triggers; it does not appoint per-workload
|
||||||
`platform-pg` ceiling in §16 is the first that requires an actual alert.
|
placement, which remains that repo's call and is the substance of the
|
||||||
|
2026-08-16 request still open there.
|
||||||
|
|
||||||
**Decision 8.2:** placement policy ownership is proposed to
|
### 7. Isolation as a product property — the commercial hazard
|
||||||
`railiance-platform`. This framework supplies the ladder, the triggers, and
|
|
||||||
the declaration; it does not appoint per-workload placement. See §18.2 — this
|
|
||||||
remains their call and their open request.
|
|
||||||
|
|
||||||
## 9. Credentials as a tenancy control
|
Plan tiers that "sell isolation" are anticipated. The hazard is precise and
|
||||||
|
worth stating before revenue depends on it:
|
||||||
|
|
||||||
Short-lived leased credentials from OpenBao, re-read at connection checkout,
|
**Selling a move from R1 to R2 as stronger tenant isolation would be false.**
|
||||||
with overlap-first rotation, bound the residual risk at every E level below
|
Per §4 it changes nothing about the tenant boundary. A customer buying
|
||||||
E4: a leaked credential expires rather than persisting. This is stronger than
|
"isolated" and receiving R2 gets better noisy-neighbour behaviour and an
|
||||||
the industry norm of a long-lived per-service secret and belongs in the
|
independent restore path — real value, and not what they think they bought.
|
||||||
tenancy model, not only in credential-management canon.
|
Only R3 changes the answer to "can another tenant's data reach mine".
|
||||||
|
|
||||||
**Decision 9.1:** static long-lived database credentials are not a sanctioned
|
**Decision:** any plan tier naming isolation MUST cite the rung it delivers
|
||||||
path for any service above E0.
|
and MUST NOT describe a lower rung in R3's language. `adaptive-pricing` owns
|
||||||
|
plan terms and `tenant-engine` owns which plan a tenant holds; neither
|
||||||
|
currently maps a tier to a rung, and §12.5 asks them to.
|
||||||
|
|
||||||
## 10. Blast radius must be published
|
## 8. Alternatives considered
|
||||||
|
|
||||||
**Decision 10.1:** every platform holding consumer data MUST publish, in
|
**Schema-per-consumer in one database.** Cheaper on connections, allows
|
||||||
concrete terms, what a leaked runtime credential can and cannot reach at the
|
cross-consumer queries. *Rejected on catalog leakage:* `pg_catalog` is
|
||||||
levels it operates. `rapp-postgres` ADR-0001 §5 is the reference
|
readable per-database, so every co-resident can enumerate every other's
|
||||||
implementation. Where the model cannot provide a guarantee, the platform says
|
table and column names regardless of `GRANT`s. Table names alone leak
|
||||||
so and names the escalation, rather than describing a weaker control in
|
business structure and no privilege setting removes that.
|
||||||
stronger language.
|
|
||||||
|
|
||||||
## 11. Commercial expression — room to be successful
|
**Row-level security as the consumer boundary.** *Rejected:* puts a
|
||||||
|
correctness-critical boundary inside query predicates, where one missing
|
||||||
|
policy or a `SECURITY DEFINER` function silently removes it. Retained as
|
||||||
|
opt-in defence in depth for the tenant boundary only (§3).
|
||||||
|
|
||||||
Isolation will be sold. The framework must let commercial and presentation
|
**Platform-enforced tenant RLS by default.** *Rejected:* with a pooled
|
||||||
work rather than obstruct them, while preventing a claim that cannot be
|
per-consumer role it depends on the consumer setting a session variable
|
||||||
evidenced.
|
correctly, producing the appearance of a database guarantee over what is
|
||||||
|
still an application check. A control that misrepresents where it is enforced
|
||||||
|
is worse than an honest absence.
|
||||||
|
|
||||||
**Decision 11.1:** plan tiers are expressed internally as **minimum levels**,
|
**Dedicated instance per consumer as the default (R2 everywhere).**
|
||||||
not as implementation labels. A tier may require `E3 P2`; it need not print
|
*Rejected as a default* on operational cost — every instance multiplies
|
||||||
that string anywhere customer-facing.
|
backup, upgrade, and monitoring surface — but it is the honest answer for a
|
||||||
|
consumer that cannot accept §9's residual risk, and it stays available as an
|
||||||
|
escalation rather than being argued away.
|
||||||
|
|
||||||
**Decision 11.2:** marketing and product language is free. There is no
|
**Instance-per-tenant everywhere (R3 as the fleet default).** *Rejected:* at
|
||||||
requirement to expose level labels, ladder names, or this document to
|
the estate's tenant grouping taxonomy — which includes `consumer` (private
|
||||||
customers. "Dedicated infrastructure", "isolated tenancy", "private instance"
|
individuals) and `family` — R3 per tenant is economically impossible. The
|
||||||
all remain available.
|
taxonomy itself is evidence that pooling is required; you cannot run a
|
||||||
|
cluster per private individual.
|
||||||
|
|
||||||
**Decision 11.3:** the constraint is on **evidence, not vocabulary**. Any
|
**One tenancy model for the whole estate.** *Rejected:* the ratified
|
||||||
customer-facing isolation or availability claim must map to a minimum level
|
business-app contract already commits client-facing apps to R3, and platform
|
||||||
the delivering service actually holds, recorded once when the tier is defined.
|
services genuinely cannot follow it. The honest answer is two defaults with a
|
||||||
The review is internal and happens at tier definition — not per campaign, per
|
stated rule for which applies, not a single default that one half violates.
|
||||||
page, or per conversation.
|
|
||||||
|
|
||||||
**Decision 11.4:** the one hard line — a claim that another tenant *cannot*
|
## 9. Challenge against outside practice
|
||||||
reach the customer's data requires **E4**. Everything softer than that
|
|
||||||
absolute has room. This is the claim that reaches compliance questionnaires
|
|
||||||
and contracts, and it is the one that must be true.
|
|
||||||
|
|
||||||
`adaptive-pricing` owns plan terms; `tenant-engine` owns which plan a tenant
|
Held against the common industry framing (AWS's silo/pool/bridge model,
|
||||||
holds. Neither currently maps a tier to minimum levels; §18.5 asks them to,
|
cell-based architectures), the estate lands as follows.
|
||||||
for tiers making isolation or availability claims only.
|
|
||||||
|
|
||||||
## 12. Methodology — analyze, establish, improve, guard
|
**Where the model is stronger than typical.** Most SaaS multi-tenancy
|
||||||
|
literature models exactly one boundary — tenant-to-tenant. This estate has
|
||||||
|
**two stacked boundaries**: platform-service-to-platform-service (the
|
||||||
|
consumer boundary) and tenant-to-tenant (the princedom boundary). Naming them
|
||||||
|
separately and refusing to enforce both with one mechanism is a genuine
|
||||||
|
strength and is uncommon.
|
||||||
|
|
||||||
The framework is operated as a cycle, not consulted as a reference.
|
The refusal to overstate the guarantee is also better than typical. Vendors
|
||||||
|
routinely describe pool-model RLS as tenant isolation. ADR-0001 §5 states the
|
||||||
|
blast radius in concrete terms instead, which is the practice worth keeping.
|
||||||
|
|
||||||
**Analyze.** Assess a repo against the four ladders and produce its current
|
**Where the model is weaker than typical, and this is the finding that
|
||||||
vector. Output: the `tenancy.current` block with the reasoning recorded.
|
matters.** The pool model's standard mitigation is a *verified* enforcement
|
||||||
Applies to new services and absorbed ones alike.
|
layer — a shared data-access library, or RLS, that every service is
|
||||||
|
demonstrably routed through. This estate has neither. §3 obliges every
|
||||||
|
consumer to enforce the tenant boundary in its own code, and there is
|
||||||
|
currently **no fleet mechanism that verifies any consumer actually does**.
|
||||||
|
|
||||||
**Establish.** Declare the target vector and the gap. The target is set by the
|
`audit-core` has AUDIT-WP-0004-T03; `tenant-engine` has its own checks;
|
||||||
data class held, the tenant groupings served, and any plan tier carried — not
|
`rapp-postgres` runs 15 adversarial probes against the *consumer* boundary
|
||||||
by ambition. Output: `tenancy.target` plus a recorded gap per plane.
|
and none against the tenant boundary inside a consumer. A single missing
|
||||||
|
`WHERE tenant_id = ?` in any consumer is a cross-tenant data breach that no
|
||||||
**Improve.** Move one plane at a time. Cross-plane moves are where mistakes
|
platform control would catch, no probe would fail, and no log would show as
|
||||||
happen — raising P while leaving E untouched is exactly the misstep §4.4
|
an error. This is the highest-severity known gap in the model and §12.3
|
||||||
warns about. Each step is ordinary repo work with ordinary evidence.
|
proposes an owner for it.
|
||||||
|
|
||||||
**Guard.** Verify continuously that the declared posture still holds. Guarding
|
|
||||||
checks a service **against its own declaration**, not against a universal
|
|
||||||
maximum. This is what makes the verification problem tractable: nobody has to
|
|
||||||
prove every service is at E4; the check is that nobody is below what they
|
|
||||||
declared, and nobody claims what they cannot evidence.
|
|
||||||
|
|
||||||
Regression found by guarding is a defect; regression declared in advance is a
|
|
||||||
decision. The estate has been bitten twice by silent pin rollbacks that
|
|
||||||
produced ordinary-looking 403s and 404s rather than errors. Posture regression
|
|
||||||
will look the same, so guarding must be designed for invisible failure rather
|
|
||||||
than for crashes.
|
|
||||||
|
|
||||||
## 13. Evidence per level
|
|
||||||
|
|
||||||
Guarding requires each level to have a defined evidence artifact. Proposed:
|
|
||||||
|
|
||||||
| Level | Evidence |
|
|
||||||
|---|---|
|
|
||||||
| **I2** | Identifiers validated against the ratified vocabulary; rejection test for a malformed tenant id. |
|
|
||||||
| **I3** | Live re-query demonstrated on an `aal2`-class path; cached-claim path shown not to be used there. |
|
|
||||||
| **A2** | Single choke point identified; test that an unbound request is refused. |
|
|
||||||
| **A3** | Live decision against `flex-auth` with a denial observed at the endpoint, not only at the decision surface. |
|
|
||||||
| **E1** | Every tenant-owned table carries the tenant key. Schema-level check, mechanical. |
|
|
||||||
| **E2** | Choke point identified; adversarial test that an identity bound to tenant A cannot read tenant B. |
|
|
||||||
| **E3** | RLS policies present on every tenant-owned table; probe that a session without the GUC set reads nothing; probe that a wrong GUC reads nothing. |
|
|
||||||
| **E4** | Per-tenant credential demonstrated unable to connect to another tenant's substrate. |
|
|
||||||
| **P1–P4** | Provisioning declaration plus the platform's own isolation probes. |
|
|
||||||
|
|
||||||
**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.2:** the E2 and E3 evidence 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. This
|
|
||||||
is the framework's largest live gap and §18.3 seeks an owner.
|
|
||||||
|
|
||||||
## 14. Alternatives considered
|
|
||||||
|
|
||||||
**One fixed model with a single set of characteristics** (draft-1). *Rejected
|
|
||||||
by the operator:* it cannot describe a repo that is not there yet, forcing
|
|
||||||
absorbed repos either to misrepresent their posture or to stay outside the
|
|
||||||
framework. A framework that can only describe its own end state is not a
|
|
||||||
framework.
|
|
||||||
|
|
||||||
**A maturity model with a single overall level** (bronze/silver/gold).
|
|
||||||
*Rejected:* collapses the plane separation that makes the framework useful. A
|
|
||||||
service strong on identity and weak on enforcement has a specific, actionable
|
|
||||||
gap; one composite score hides it and invites averaging.
|
|
||||||
|
|
||||||
**Schema-per-consumer in one database** (sanctioning P0). *Rejected:*
|
|
||||||
`pg_catalog` is readable per-database, so every co-resident enumerates every
|
|
||||||
other's table and column names regardless of `GRANT`s. Retained in the ladder
|
|
||||||
as a describable state, never as a sanctioned target.
|
|
||||||
|
|
||||||
**Row-level security as the *consumer* boundary.** *Rejected:* puts a
|
|
||||||
correctness-critical boundary between two services inside query predicates.
|
|
||||||
Retained as E3 for the *tenant* boundary, where the realistic alternative is
|
|
||||||
application code rather than a database.
|
|
||||||
|
|
||||||
**Mandating E4 for everyone.** *Rejected:* the tenant grouping taxonomy
|
|
||||||
includes `consumer` (private individuals) and `family`. A cluster per private
|
|
||||||
individual is economically impossible; the taxonomy is itself evidence that
|
|
||||||
pooling is required.
|
|
||||||
|
|
||||||
**Leaving each repo to its own model.** *Rejected:* this is the status quo,
|
|
||||||
and it produced two contradictory ratified defaults plus an unowned placement
|
|
||||||
question.
|
|
||||||
|
|
||||||
## 15. Challenge against outside practice
|
|
||||||
|
|
||||||
**Where the framework is stronger than typical.** Most SaaS multi-tenancy
|
|
||||||
literature (AWS's silo/pool/bridge framing among them) models exactly 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-plane levels are also stronger than the usual silo/pool/bridge
|
|
||||||
trichotomy, which describes deployment shape only. Silo/pool/bridge is
|
|
||||||
approximately this framework's P plane with the other three missing — which is
|
|
||||||
why it cannot express "pooled infrastructure, structurally enforced
|
|
||||||
boundary", or the reverse.
|
|
||||||
|
|
||||||
**Where it is weaker, and this is the finding that matters.** The pool model's
|
|
||||||
standard mitigation is a *verified* enforcement layer that every service is
|
|
||||||
demonstrably routed through. The estate has the concept (E2/E3) and none of
|
|
||||||
the verification (§13.2). A single missing `WHERE tenant_id = ?` in any
|
|
||||||
consumer is a cross-tenant breach that no platform control catches, no probe
|
|
||||||
fails, and no log shows as an error.
|
|
||||||
|
|
||||||
**Where the estate has quietly adopted a good pattern without naming it.**
|
**Where the estate has quietly adopted a good pattern without naming it.**
|
||||||
Short-lived leased credentials re-read at checkout, with overlap-first
|
Short-lived leased credentials from OpenBao, re-read at connection checkout,
|
||||||
rotation, beat the industry norm of a long-lived per-service secret. §9
|
with overlap-first rotation, is stronger than the industry norm of a
|
||||||
promotes it to a tenancy control.
|
long-lived per-service secret. It bounds §9's residual risk by credential
|
||||||
|
lifetime. It deserves to be stated as a tenancy control, not just a
|
||||||
|
credential-management one.
|
||||||
|
|
||||||
**Cell-based architecture remains unexplored.** Neither P nor E describes a
|
**Cell-based architecture is the unexplored option.** R2 and R3 partition by
|
||||||
cell — a fixed-size slice of infrastructure serving a bounded set of tenants,
|
consumer or by tenant. Neither partitions by *cell* — a fixed-size slice of
|
||||||
sized so one cell's failure is survivable and cell count scales linearly.
|
infrastructure serving a bounded set of tenants, sized so that one cell's
|
||||||
Given the estate spans railiance, NetKingdom, HelixForge and Coulomb, cells
|
failure is survivable and cell count scales linearly. Given the estate spans
|
||||||
may fit better than per-tenant clusters at the point P1 runs out (§18.8).
|
railiance, NetKingdom, HelixForge and Coulomb with tenants layered on top,
|
||||||
|
cells may fit better than per-tenant clusters at the point where R1 runs out.
|
||||||
|
Not proposed here; flagged in §12.6 as worth evaluating before the ceiling in
|
||||||
|
§10 is hit rather than after.
|
||||||
|
|
||||||
## 16. Scaling demands
|
## 10. Scaling demands — the ceiling is closer than it looks
|
||||||
|
|
||||||
Measured against the live `platform-pg` specification, not estimated.
|
Measured against the live `platform-pg` specification, not estimated.
|
||||||
|
|
||||||
|
|
@ -471,84 +293,90 @@ Measured against the live `platform-pg` specification, not estimated.
|
||||||
instances: 1 (no HA; single-node rail)
|
instances: 1 (no HA; single-node rail)
|
||||||
max_connections: 100
|
max_connections: 100
|
||||||
memory limit: 1Gi
|
memory limit: 1Gi
|
||||||
|
cpu limit: 1
|
||||||
|
storage: 20Gi
|
||||||
per consumer: 14 connections (12 runtime + 2 migration)
|
per consumer: 14 connections (12 runtime + 2 migration)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Connection ceiling: roughly six consumers.** Seven consumers request 98 of
|
**Connection ceiling: roughly six consumers.** At 14 connections per consumer
|
||||||
100 before CNPG's instance manager, metrics exporter, and reserved slots. Two
|
declaration, seven consumers request 98 of 100 connections, before CNPG's
|
||||||
are provisioned today. **We are at roughly a third of capacity and the third
|
instance manager, the metrics exporter, and superuser-reserved slots. The
|
||||||
request will not feel like a scaling event.**
|
practical ceiling is about **six**. Two are provisioned today
|
||||||
|
(`audit-core`, `tenant-engine`) plus the isolation probe. **We are at
|
||||||
|
roughly a third of capacity, and the third consumer request will not feel
|
||||||
|
like a scaling event.**
|
||||||
|
|
||||||
**Memory likely binds first.** 100 backends against 1Gi is ~10MB per backend
|
**Memory is likely to bind before connections do.** 100 PostgreSQL backends
|
||||||
for shared buffers, work_mem and overhead. Connection exhaustion errors
|
against a 1Gi limit is roughly 10MB per backend for all of shared buffers,
|
||||||
clearly; memory pressure OOM-kills and degrades every co-resident at once.
|
work_mem, and per-backend overhead. Connection exhaustion produces a clear
|
||||||
|
error; memory pressure produces OOM kills and degraded latency across every
|
||||||
|
co-resident consumer at once.
|
||||||
|
|
||||||
**Raising E is not free on a shared substrate.** RLS with a per-checkout GUC
|
**Restore time couples all consumers.** Physical backup is instance-wide, so
|
||||||
does not itself increase connection count, but it forecloses aggressive
|
restoring one consumer means restoring the whole instance to scratch, taking
|
||||||
transaction-level pooling for consumers that adopt it. E3 on P1 interacts with
|
a logical dump, and importing. A consumer's RTO is therefore a function of
|
||||||
the ceiling above and should be planned against it.
|
**total instance size**, not its own. One large consumer silently degrades
|
||||||
|
every co-resident's recovery promise — and per the storage draft §7.3, a
|
||||||
|
consumer must not promise retention or recovery its platform cannot back.
|
||||||
|
|
||||||
**Restore time couples all consumers.** Physical backup is instance-wide, so a
|
**`instances: 1` means no tenant on `platform-pg` has HA.** Deliberate on the
|
||||||
consumer's RTO is a function of *total* instance size, not its own. One large
|
current rail and honestly documented, but it means every shared consumer's
|
||||||
consumer silently degrades every co-resident's recovery promise.
|
availability story is "restart recovery", not high availability. A plan tier
|
||||||
|
promising uptime cannot be satisfied at R1 as currently built.
|
||||||
|
|
||||||
**`instances: 1` means no P1 tenant has HA.** A tier promising uptime cannot
|
**What this implies.** The ladder is not a long-term growth path on the
|
||||||
be satisfied at P1 as currently built — an availability floor belongs in §11's
|
present substrate — it is a two-to-four-consumer runway. The triggers in §6
|
||||||
minimum-level vocabulary alongside isolation.
|
must be monitored, not merely recorded, and connection and memory
|
||||||
|
utilisation belong on the alert list *before* the next consumer is accepted.
|
||||||
|
|
||||||
## 17. Consequences
|
## 11. Consequences
|
||||||
|
|
||||||
- The estate gains one vocabulary and, more importantly, a way to be honest
|
- The estate gains one vocabulary for a conversation currently held in four
|
||||||
about partial adoption.
|
incompatible ones.
|
||||||
- Absorbed repos get a described state and a path instead of a failing grade.
|
|
||||||
- `tenantIsolation` in `PostgresConsumer` is revealed as a mislabelled field
|
- `tenantIsolation` in `PostgresConsumer` is revealed as a mislabelled field
|
||||||
(§18.1).
|
and should be renamed or moved once this ADR settles (§12.1).
|
||||||
- The two ratified defaults are reconciled by scope, at the cost of needing a
|
- Two ratified defaults (R1 for platform services, R3 for business apps) are
|
||||||
classification rule.
|
reconciled by scope rather than by one overriding the other, at the cost of
|
||||||
- The verification problem becomes tractable: guard against declaration, not
|
needing a rule for classifying a new service.
|
||||||
against a universal maximum.
|
- The unverified tenant boundary (§9) becomes a named, owned gap rather than
|
||||||
- Draft-1's prohibition on RLS is reversed; `rapp-postgres` acquires an
|
an assumption distributed across every repo.
|
||||||
obligation to define and offer the E3 mechanism.
|
- The `platform-pg` ceiling becomes a planning input rather than a discovery.
|
||||||
- Nothing here changes a running system.
|
- Nothing in this ADR changes a running system. It is descriptive of what is
|
||||||
|
built, plus five decisions that need owner sign-off.
|
||||||
|
|
||||||
## 18. Open questions
|
## 12. Open questions for ratification
|
||||||
|
|
||||||
1. **`tenantIsolation` field** — `rapp-postgres`: rename to name its plane and
|
1. **`tenantIsolation` field** — `rapp-postgres`: rename to name its plane
|
||||||
carry a level (`tenancy.E: 2`), or move it out of the storage declaration.
|
(`tenantBoundaryEnforcement`?), or move it out of the storage declaration
|
||||||
2. **Placement ownership** — `railiance-platform`: accept the ladder, the
|
entirely.
|
||||||
triggers, and the monitoring obligation in §8.1, and appoint a recorded
|
2. **Placement ownership** — `railiance-platform`: accept the ladder and
|
||||||
placement owner per workload.
|
triggers, and appoint a recorded placement owner per workload. This is the
|
||||||
3. **E2/E3 evidence artifacts** — *owner needed.* Who builds the adversarial
|
2026-08-16 request, still open.
|
||||||
cross-tenant probes and the RLS conformance check? Both `audit-core` and
|
3. **Tenant-boundary conformance** — *owner needed*. Who builds and runs a
|
||||||
`tenant-engine` have declined fleet-scope work on correct boundary
|
fleet check that a consumer cannot leak tenant A's rows to tenant B?
|
||||||
reasoning, so this needs appointing. Highest-severity gap (§13.2).
|
`audit-core` and `tenant-engine` have both declined fleet-scope work on
|
||||||
4. **Business app vs platform service** — Custodian canon: a classification
|
correct boundary reasoning, so this needs appointing rather than
|
||||||
rule, so a new repo knows whether P3 or P1 is its default. Candidate: reuse
|
volunteering. Highest-severity gap in the model.
|
||||||
`repo-classification-standard_v1.0` rather than invent a second taxonomy.
|
4. **Business app vs platform service** — *Custodian canon*: a definition, so
|
||||||
5. **Tier → minimum level mapping** — `adaptive-pricing` and `tenant-engine`:
|
a new repo knows whether §1.2's R3 default or this ADR's R1 default
|
||||||
required only for tiers making isolation or availability claims (§11).
|
applies to it.
|
||||||
6. **E3 mechanism** — `rapp-postgres`: what exactly is the GUC contract, and
|
5. **Plan tier → rung mapping** — `adaptive-pricing` and `tenant-engine`:
|
||||||
is it provisioned per consumer on request or offered as a standard option?
|
no tier may name isolation without citing a rung (§7).
|
||||||
7. **Engine neutrality** — the P ladder rests on a PostgreSQL property
|
6. **Cell-based partitioning** — evaluate before the §10 ceiling is reached.
|
||||||
(per-database catalogs). State it engine-specifically and say so, or
|
7. **Engine neutrality** — whether the ladder should be stated
|
||||||
abstract it and risk a non-Postgres implementation that silently differs?
|
engine-neutrally or admit that R1's guarantee rests on a PostgreSQL
|
||||||
Inherited unresolved from the v0.1 draft.
|
property (per-database catalogs). Inherited unresolved from the storage
|
||||||
8. **Cell-based partitioning** — evaluate at consumer #4 rather than now?
|
draft.
|
||||||
Neither P nor E describes a cell.
|
|
||||||
|
|
||||||
## 19. Ratification path
|
## 13. Ratification path
|
||||||
|
|
||||||
The predecessor draft stalled because it was routed without one.
|
The predecessor draft stalled because it was routed without one. This ADR:
|
||||||
|
|
||||||
1. Reviewed by `tenant-engine`, `flex-auth`, `rapp-postgres`,
|
1. Is reviewed by `tenant-engine`, `flex-auth`, `rapp-postgres`,
|
||||||
`railiance-platform`, and `adaptive-pricing` against §18.
|
`railiance-platform`, and `adaptive-pricing` against §12.
|
||||||
2. Each of those repos publishes its own posture vector (§5) as part of
|
2. On acceptance, **supersedes** the routing of
|
||||||
review. **The framework is validated by whether it can describe them
|
|
||||||
accurately** — if a repo cannot express itself in these four 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`,
|
`rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1-draft.md`,
|
||||||
whose §§3–8 are absorbed here. That draft is then withdrawn rather than
|
whose §§3–8 are absorbed above. That draft should then be withdrawn rather
|
||||||
left pending.
|
than left pending, so the estate does not carry two overlapping proposals.
|
||||||
4. On acceptance, `rapp-postgres/docs/adr/ADR-0001` moves to `accepted` and is
|
3. On acceptance, `rapp-postgres/docs/adr/ADR-0001` moves from `proposed` to
|
||||||
annotated as the PostgreSQL implementation of the E and P ladders.
|
`accepted` and is annotated as the PostgreSQL implementation of this model.
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
---
|
|
||||||
id: ADR-009
|
|
||||||
type: architecture-decision-record
|
|
||||||
title: "Federated Namespaces, Qualified Identifiers, and the Limits of Reconciliation"
|
|
||||||
status: proposed
|
|
||||||
decided_by: Bernd Worsch
|
|
||||||
date: "2026-08-17"
|
|
||||||
tags: ["architecture", "federation", "namespace", "identity", "reconciliation", "fos", "adr-007", "adr-008"]
|
|
||||||
---
|
|
||||||
|
|
||||||
# ADR-009: Federated Namespaces, Qualified Identifiers, and the Limits of Reconciliation
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Proposed. Amends `ADR-007` decision 1 and extends `ADR-008` decision 4.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The ecosystem must be **federation-capable** — able to work without a live link
|
|
||||||
to central services — and **chain-of-command efficient**. These read as opposing
|
|
||||||
requirements, but `federated-organization-standard_v1.0` already resolves the
|
|
||||||
organizational half:
|
|
||||||
|
|
||||||
> global coherence is achieved through policy, escalation, and shared protocols
|
|
||||||
> rather than through monolithic control (`:51`)
|
|
||||||
>
|
|
||||||
> bounded autonomy plus disciplined coordination (`:86`)
|
|
||||||
|
|
||||||
Authority is a **policy** layer; availability is a **mechanism**. Nothing
|
|
||||||
requires the mechanism to depend on reaching the authority — the same separation
|
|
||||||
distributed version control makes, where an integration branch is privileged by
|
|
||||||
agreement rather than by any technical property.
|
|
||||||
|
|
||||||
**One accepted decision does violate this.** `ADR-007` decision 1 requires
|
|
||||||
running numbers that are *globally* unique and allocated forward only. Global
|
|
||||||
sequential allocation requires a coordinator — precisely the central dependency
|
|
||||||
federation must survive. Two disconnected forks each allocating
|
|
||||||
`RCLUSTER-WP-0015` is not an edge case but a certainty.
|
|
||||||
|
|
||||||
This is not hypothetical. Instance-per-client tenancy (`CUST-WP-0058`) already
|
|
||||||
means each client deployment is a fork of the stack; the business model committed
|
|
||||||
to forking before the identifier scheme could express it.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**1. Records live in a namespace.** A namespace is a federated branch of the
|
|
||||||
ecosystem — a fleet instance, a client deployment, an autonomous domain that may
|
|
||||||
operate disconnected. It is the unit of sovereignty in FOS terms, and the unit of
|
|
||||||
divergence here.
|
|
||||||
|
|
||||||
**2. Identifiers are unique within a namespace, qualified across namespaces.**
|
|
||||||
`ADR-007` decision 1 is amended: uniqueness and forward-only allocation are
|
|
||||||
**namespace-scoped**, not global. Global identity is the pair
|
|
||||||
`(namespace, identifier)`.
|
|
||||||
|
|
||||||
Notation is a suffix:
|
|
||||||
|
|
||||||
```text
|
|
||||||
RCLUSTER-WP-0015 this namespace — the common case, unchanged
|
|
||||||
RCLUSTER-WP-0015@railiance01 a foreign namespace, explicitly qualified
|
|
||||||
```
|
|
||||||
|
|
||||||
Unqualified always means "in my own namespace", exactly as `main` does against
|
|
||||||
`origin/main`. A suffix keeps existing sorting, grep, and tooling working; a
|
|
||||||
prefix would break them.
|
|
||||||
|
|
||||||
**3. Deterministic identifiers derive from the pair.** `ADR-007` decision 2 (C2)
|
|
||||||
derives UUIDv5 from `PREFIX-WP-NNNN`. Under federation the derivation input must
|
|
||||||
be `(namespace, identifier)`. Deriving from the identifier alone would make two
|
|
||||||
forks holding unrelated work under the same number compute the *same* UUID —
|
|
||||||
a collision manufactured by the fix. C2 is not yet built, so this is free now and
|
|
||||||
expensive later.
|
|
||||||
|
|
||||||
**4. Records carry lineage.** A namespace records what it forked from and at what
|
|
||||||
point; a record forked from another namespace records its origin. Without
|
|
||||||
lineage, a shared identifier is ambiguous between *the same work, diverged* and
|
|
||||||
*different work, colliding* — a distinction that cannot be reconstructed after
|
|
||||||
the fact.
|
|
||||||
|
|
||||||
**5. Mutable state is derived from an append-only log.** Extending `ADR-008`
|
|
||||||
decision 4: status and other mutable fields are a fold over append-only events
|
|
||||||
rather than fields to be reconciled. Reconciliation then becomes union-of-events
|
|
||||||
plus re-derivation — deterministic, order-independent, and requiring no
|
|
||||||
conflict model. This collapses the hardest part of reconciliation into the part
|
|
||||||
that was already free.
|
|
||||||
|
|
||||||
**6. Coexistence is the normal case; merging is the exception.** The qualifier is
|
|
||||||
primarily a **coexistence** mechanism, not merge preparation. Most forks —
|
|
||||||
client instances above all — are never intended to return. The architecture must
|
|
||||||
support permanent parallel operation as a first-class outcome, not as a failure
|
|
||||||
to reconcile.
|
|
||||||
|
|
||||||
**7. What may fork.** Canon, plans, knowledge, and records fork freely. **Effects
|
|
||||||
do not.** Two namespaces may hold divergent plans about one production database;
|
|
||||||
they cannot hold divergent production databases. Work that acts on shared
|
|
||||||
operational infrastructure is not fork-safe and must be coordinated through the
|
|
||||||
owning namespace.
|
|
||||||
|
|
||||||
## Reconciliation tiers, and where automation stops
|
|
||||||
|
|
||||||
| Tier | Situation | Mechanism | Automatable |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **T0 Fork** | namespace created, lineage recorded | metadata | yes |
|
|
||||||
| **T1 Sync** | pull canon, plans, or knowledge from another namespace | append events, re-derive | largely |
|
|
||||||
| **T2 Reintegration** | fork returns; records diverged, **no independent operational infrastructure** | union events, re-derive, human-resolve genuine contradictions | partly |
|
|
||||||
| **T3 Amalgamation** | **distinct operational infrastructure**, identity, credentials, data stores, possibly distinct canon | governed programme — see below | **no** |
|
|
||||||
|
|
||||||
**The T2/T3 test is whether the fork established its own operational
|
|
||||||
infrastructure** — stateful systems with independently diverged state.
|
|
||||||
|
|
||||||
**T3 is not a merge.** It resembles the merger or acquisition of distinct
|
|
||||||
companies far more than a version-control merge, and must be planned as such:
|
|
||||||
|
|
||||||
- due diligence — inventory both sides, item by item;
|
|
||||||
- survivor decisions per subsystem — keep, move, replace, retire;
|
|
||||||
- migration with evidence, and decommissioning of what does not survive;
|
|
||||||
- governance integration — reconciling two bodies of canon is a human act,
|
|
||||||
never automated;
|
|
||||||
- identity, credential, and data custody consolidation.
|
|
||||||
|
|
||||||
**T3 may legitimately end without integration.** Permanent coexistence, or
|
|
||||||
divestment, are valid outcomes. Real mergers most often fail at systems
|
|
||||||
integration; an architecture that assumes eventual convergence is lying about
|
|
||||||
its own limits.
|
|
||||||
|
|
||||||
### Known cost
|
|
||||||
|
|
||||||
`prj-state-hub-retirement` is a T3 reconciliation inside a *single*
|
|
||||||
organization — two hub implementations with distinct infrastructure. It required
|
|
||||||
a dedicated project repository, a 425-item disposition inventory (`SHR-INV-0001`),
|
|
||||||
staged migration with strangler adapters, and explicit retirement gates. That is
|
|
||||||
the realistic cost of T3 under the most favourable possible conditions: one
|
|
||||||
owner, one canon, one credential authority.
|
|
||||||
|
|
||||||
Nothing in this ADR reduces that cost. It ensures the cost is **visible before**
|
|
||||||
a fork is created rather than discovered when reconciliation is attempted.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive.** Disconnected operation stops requiring a coordinator for identity.
|
|
||||||
`ADR-007` decision 1's contradiction is resolved rather than tolerated. Client
|
|
||||||
instances become expressible. Reconciliation limits are stated rather than
|
|
||||||
implied.
|
|
||||||
|
|
||||||
**Negative.** Every record gains a namespace and lineage, and tooling must learn
|
|
||||||
qualified references. The fork-boundary rule (decision 7) constrains what
|
|
||||||
disconnected work may legitimately do — deliberately.
|
|
||||||
|
|
||||||
**Deferred.** Merge machinery for T2 is **not** specified here and should not be
|
|
||||||
built until a real fork exists and needs it. T3 needs no machinery by definition;
|
|
||||||
it needs a project repository and human judgement.
|
|
||||||
|
|
||||||
**Amended.** `ADR-007` decision 1 — uniqueness is namespace-scoped, not global.
|
|
||||||
`ADR-007` decision 2 — derivation input becomes `(namespace, identifier)`.
|
|
||||||
|
|
||||||
## Open question
|
|
||||||
|
|
||||||
**What is the namespace boundary in practice?** Candidates: a fleet instance
|
|
||||||
(`railiance01`), a client deployment, or an FOS autonomous domain. FOS's
|
|
||||||
recursion ("from projects to companies to foundation-like umbrella structures")
|
|
||||||
suggests namespaces nest, which would make qualification relative rather than
|
|
||||||
absolute. Settle before implementation; the notation works either way.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `canon/standards/federated-organization-standard_v1.0.md` — bounded autonomy,
|
|
||||||
escalation, sovereignty by default, rebuildability
|
|
||||||
- ADR-001 — workplans originate as repo files
|
|
||||||
- ADR-007 — identifier uniqueness and derived identifiers (amended here)
|
|
||||||
- ADR-008 — hub authority, local cache, and the two kinds of hub data
|
|
||||||
- `CUST-WP-0058` — instance-per-client tenancy
|
|
||||||
- `SHR-INV-0001` — 425-item disposition inventory, T3 cost evidence
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue