From 753f369c2cccbe64fae067396d755818bed11d1c Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 9 Sep 2026 03:51:38 +1000 Subject: [PATCH 1/7] docs(decisions): TS/RS namespace isolation direction (LAB-640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the stage-1 direction for epic LAB-680: server-side namespace isolation (allowed_namespaces ACL, per-namespace quotas, the ns:/nsapi: write-space split) is driven only by the key prefix, and only cachekit-py emits ns:. So TS/RS SDK namespaces and interop-mode namespaces are client-side conventions, scoped server-side to default/open. Chooses option 2 (document the asymmetry; no key-format change) over option 1 (TS/RS adopt ns: — key-stability break, billed-miss migration, and it still leaves interop in default) and option 3 (per-key default-namespace server override — deferred, reopenable). Interop keys stay in default by the existing spec pin (isolation from authentication, not key parsing). Documentation-only: no spec key-format change, no server change. Proposed (accepted on merge) — the epic owner's merge is the ratification. --- CHANGELOG.md | 17 ++ decisions/namespace-isolation.md | 289 +++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 decisions/namespace-isolation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7954064..ada0a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to the CacheKit Protocol Specification. ## [Unreleased] +### Decision — TS/RS namespace isolation is a Python-SDK + `nsapi:` feature (LAB-640) + +- New decision record + [decisions/namespace-isolation.md](decisions/namespace-isolation.md): records + the stage-1 direction for epic LAB-680. Server-side namespace isolation + (`allowed_namespaces` ACL, per-namespace quotas, the `ns:`/`nsapi:` + write-space split) is driven only by the key prefix, and **only cachekit-py + emits `ns:`** — so TS/RS SDK namespaces and interop-mode namespaces are + **client-side conventions**, scoped server-side to `default`/`open`. Chooses + **option 2** (document the asymmetry; no key-format change) over option 1 + (TS/RS adopt `ns:` — rejected: key-stability break, billed-miss migration, + and it still leaves interop in `default`) and option 3 (per-key + default-namespace server override — deferred, reopenable). Interop keys stay + in `default` by the existing spec pin (isolation from authentication, not key + parsing). Documentation-only: no spec key-format change, no server change. + **Proposed (accepted on merge)** — the epic owner's merge is the ratification. + ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) - LZ4 compressed bytes are **not canonical** across conforming block encoders. diff --git a/decisions/namespace-isolation.md b/decisions/namespace-isolation.md new file mode 100644 index 0000000..5d1f07d --- /dev/null +++ b/decisions/namespace-isolation.md @@ -0,0 +1,289 @@ +**[Protocol](../README.md)** > **Decisions** > **TS/RS Namespace Isolation** + +# Decision Record: Namespace Isolation Is a Python-SDK + Direct-API Feature; TS/RS Namespaces Are Client-Side Conventions + +| | | +| :--- | :--- | +| **Status** | Proposed (accepted on merge) | +| **Date** | 2026-09-09 | +| **Ticket** | LAB-640 (stage-1 keystone of epic LAB-680), found by the LAB-627 namespace-isolation audit | +| **Precedent** | [cachekit-io/protocol#17](https://github.com/cachekit-io/protocol/pull/17) (author Ray) — documents the *server* contract ("the 7-segment grammar is Python SDK convention, not a server contract"; unprefixed keys are an open write space). This record chooses the *SDK* direction that precedent implies. | +| **Normative spec** | [`spec/cache-key-format.md` → Full Key Structure](../spec/cache-key-format.md#full-key-structure) (the `ns:{namespace}:` prefix) and [`spec/interop-mode.md` → SaaS Considerations](../spec/interop-mode.md#saas-considerations) (interop keys carry no `ns:` prefix). This record owns the rationale and the cross-SDK story; the specs own the rules. | +| **Implementation** | Documentation-only. No key-format change, no server change, no SDK code change. Follow-ups: the [feature matrix](../sdk-feature-matrix.md) namespace-semantics row (LAB-646) and the stage-2/3 hardening children (LAB-641/642/645, LAB-643/644) are re-pointed to this direction, not to a key-format rewrite. | + +--- + +## Context + +Namespace-based access control on the CachekitIO SaaS backend — the +`allowed_namespaces` ACL, per-namespace quotas, and the `ns:`/`nsapi:` +intra-tenant write-space split — is driven **entirely by the key prefix**. The +server treats every cache key as an opaque string and parses only the leading +`ns:{namespace}:` / `nsapi:{namespace}:` segment; a key without that prefix is +scoped to `namespace='default'`, `keyClass='open'`, writable by every key class +(verified on `saas` `main`, 2026-09-09): + +- `apps/cache/src/cache-key-validator.ts:118-120` — the fall-through return for + a non-prefixed key: `{ ok: true, key, namespace: 'default', keyClass: 'open' }`, + with the code comment naming the callers by SDK: *"Non-prefixed key (TS/Rust + SDK format, interop mode, bare hash): scoped to the `default` namespace."* +- `apps/cache/src/index.ts:726-762` — *"The SaaS treats keys as opaque strings; + only the ns:/nsapi: namespace prefix is load-bearing server-side (tenant + namespace isolation, quotas)."* The write-space `403`s fire only when + `keyClass` is `sdk` or `api`, i.e. only on prefixed keys. + +**Only cachekit-py emits the `ns:` prefix.** Its auto-mode key generator +prepends `ns:{namespace}:` when a namespace is set +([`spec/cache-key-format.md:36,41`](../spec/cache-key-format.md#full-key-structure)). +The other SDKs do not (verified in the LAB-3179 grooming sweep, 2026-09-09, on +each repo's `main`): + +- **cachekit-ts** emits `{namespace}:{hex}` with no `ns:` prefix + (`packages/cachekit/src/serialization/key-generator.ts:21,44`); its own + examples even put colons *inside* the namespace (`:23,:30`). +- **cachekit-rs** `get`/`set` take caller keys and prepend `{namespace}:` when + `.namespace()` is set — still no `ns:` (`crates/cachekit/src/client.rs:246-250`). +- **Interop mode** keys are `{namespace}:{operation}:{args_hash}`, spec-pinned to + **no `ns:` prefix** + ([`spec/interop-mode.md:374-376`](../spec/interop-mode.md#saas-considerations)): + *"the `{namespace}` segment is an SDK-level convention, not a SaaS routing + element (tenant isolation comes from authentication, not key parsing)."* + +### Consequence — the thing this record settles + +The rule is not specific to TS and RS: **because only cachekit-py emits `ns:`, +every non-Python SDK is affected identically** — TS, RS, and any other SDK in +the fleet (the [feature matrix](../sdk-feature-matrix.md) also lists PHP), plus +interop mode. For all of them the SDK-level "namespace" is a **client-side +convention only**; it is invisible to server-side isolation. Concretely, within +one tenant: + +- Two TS/RS apps cannot be isolated from each other by API-key namespace grants — + all their keys land in `default`. +- An API key restricted to `['default']` can read and write **all** TS/RS/interop + traffic in the tenant. +- Per-namespace quotas cannot scope TS/RS keys — the GLOB pattern never matches an + unprefixed key. + +This asymmetry is real, it is security-relevant, and — critically — **it is +undocumented**. Nothing in the SDK docs or +[`sdk-feature-matrix.md`](../sdk-feature-matrix.md) tells a reader that only +Python participates in layer-2 (within-tenant) isolation. The bug is not that the +server behaves this way; the bug is that a reader cannot find out that it does. +This record closes the **documentation** defect (the silence); it does **not** +close the isolation gap itself — that gap is accepted as a recorded residual +risk (see [Residual risk](#residual-risk-accepted-not-closed) below). + +## Options + +Three directions were considered. The decision is **option 2**. + +### 1. TS/RS adopt the `ns:{namespace}:` prefix in generated keys — rejected + +Make every SDK emit `ns:{namespace}:...` so the server's isolation applies +uniformly. Rejected on three grounds, in priority order: + +1. **It is a cache-key-format change**, so it trips the crypto/protocol + expert-panel gate before any line ships, and it must be specced as a + normative key-format revision across four SDKs — the largest possible blast + radius for the smallest possible win here (the win is available for free under + option 2's documentation + existing `nsapi:` path). +2. **It is a key-stability break.** Changing the generated key for existing + deployments orphans every entry already written under the old format. Under + the metered-misses SaaS pricing model, those orphans convert directly into + **billed misses** and a fleet-wide miss storm on cut-over — the same failure + class the [cache-key-format spec warns against by name](../spec/cache-key-format.md#test-vectors) + ("a changed key orphans every existing cache entry and turns the fleet's hits + into billed misses"). +3. **It does not even solve interop.** Interop keys are spec-pinned to no `ns:` + prefix (see the interop story below), so uniform SDK prefixing would still + leave all cross-SDK interop traffic in `default`. Option 1 buys a migration + and still ships a documented asymmetry. + +### 2. Document namespace isolation as Python-SDK + direct-API (`nsapi:`); TS/RS namespaces are client-side conventions — **chosen** + +State plainly, everywhere a reader would look, that server-side namespace +isolation is delivered by: + +- the **Python SDK** (which emits `ns:{namespace}:`), and +- the **direct HTTP API** using the `nsapi:{namespace}:{key}` write space, + +and that **TS/RS SDK namespaces, and interop-mode namespaces, are client-side +key-organisation conventions with no server-side isolation, quota, or ACL +effect** — everything they write is `default`/`open`. This is the direction the +server contract already documents in the owner's own words +([cachekit-io/protocol#17](https://github.com/cachekit-io/protocol/pull/17)); this +record makes it the recorded SDK-level decision and forces the docs to say it. + +Chosen because it is the honest description of the shipped system, it carries +**no key-stability break and no billed-miss migration**, it needs **no server +work**, and it closes the actual defect (the silent, undocumented asymmetry) +rather than papering it with a migration whose interop hole would keep the +asymmetry anyway. + +### 3. Server-side per-API-key default-namespace override — rejected (deferred, not foreclosed) + +Let an API key carry a server-side "default namespace" so that unprefixed keys +from a given key are mapped into that namespace instead of `default`. This would +give TS/RS keys isolation with no SDK-side key change. Rejected as the direction +of record because: + +1. **It is net-new server behaviour and state** (a per-key namespace attribute, + plus the mapping logic and its interaction with the write-space split and + quotas) — real design, real testing, real security surface — for a problem + that option 2 resolves by documentation. +2. **It changes what a key means depending on who presents it**, which is a + sharper edge than the status quo: the same unprefixed key now routes to + different namespaces per API key, complicating shared-cache and interop + reasoning. + +It is recorded as **deferred, not foreclosed**: if a customer later needs +server-side isolation for TS/RS without an SDK change, option 3 is the path to +reopen — as a new decision, with its own gate. + +## Decision + +**Adopt option 2.** Server-side namespace isolation is a **Python-SDK + +direct-`nsapi:`-API** feature. TS/RS SDK namespaces and interop-mode namespaces +are **client-side conventions** with no server-side isolation, quota, or ACL +effect; keys without an `ns:`/`nsapi:` prefix are scoped to `default`/`open` and +are mutually readable and writable within a tenant. No cache-key format changes. + +Points that are decision, not mechanism: + +- **The asymmetry is documented, not removed.** The fix makes the shipped + behaviour discoverable; the isolation gap itself is accepted (see Residual + risk). +- **`nsapi:` is the isolation path for *direct-API* writers — not a drop-in for + the SDKs.** A caller that needs true server-side namespace isolation without + Python uses the `nsapi:{namespace}:{key}` write space explicitly. Two caveats a + reader must not miss: (a) **no SDK emits `nsapi:`** — the TS/RS keygens produce + `{namespace}:{...}` and interop is grammar-pinned, so reaching `nsapi:` means + hand-crafting keys against the raw HTTP API, bypassing SDK key generation; and + (b) `nsapi:` is the **`api`-class** write space, distinct from Python's + **`sdk`-class** `ns:` space — they do not share a namespace for *writes* (the + write-space `403`s enforce the split; reads are open to both within a tenant). + Adopting `nsapi:` for keys currently written unprefixed **re-keys** them, + orphaning the existing `default`-scoped entries into billed misses — the same + cost class as option 1, but **scoped and opt-in** (one caller's keys) rather + than a fleet-wide format break. Emitting `ns:`/`nsapi:` from the SDKs by default + is out of scope here (that is option 1, rejected). +- **No key-format change ⇒ the crypto/protocol expert-panel gate does not gate + implementation** here. This record still passes the expert-panel *design* gate + before merge (Size M, `security`); AC3 is satisfied vacuously — no key-format + change means no orphaned-entry/billed-miss migration to schedule. + +### The interop story (required by AC2) + +Interop-mode keys stay in `default` server-side, and this is deliberate. They are +spec-pinned to carry **no `ns:` prefix** +([`spec/interop-mode.md:374-376`](../spec/interop-mode.md#saas-considerations)): +the `{namespace}` segment is a cross-SDK key-organisation convention, and tenant +isolation for interop comes from **authentication**, not key parsing. This +decision does **not** change that pin. + +That interop keys are *accepted at all* by the SaaS is now live: the CachekitIO +validator was shrunk to security-only checks (saas#91), so interop keys pass the +charset whitelist and fall through to `default`/`open` rather than being rejected +by the old auto-mode grammar (verified on `saas` `main`, +`apps/cache/src/cache-key-validator.ts` header + fall-through, 2026-09-09). The +`interop-mode.md` WARNING that "the deployed validator … would reject +interop-format keys" (`:378-384`) predates saas#91 and is now **stale** — its +cleanup is part of LAB-646, not a blocker on this decision. + +Interop is therefore a *within-tenant-shared* space: within a tenant, interop +entries are mutually accessible regardless of their `{namespace}` segment. +Cross-tenant separation holds **only insofar as the auth-layer tenant scoping is +correct** — it is the *single* control (interop keys carry no tenant routing, so +there is no second layer behind it), and sibling ticket LAB-644 tracks a live +threat to exactly that assumption (RS conflating namespace with encryption +tenant; TS AAD default mismatch). Option 1 would not have improved any of this +(interop cannot take an `ns:` prefix without breaking the cross-SDK grammar), +which is the third reason it was rejected. + +## Residual risk (accepted, not closed) + +Option 2 closes the **documentation** defect. It explicitly **accepts** the +underlying isolation gap as a recorded risk rather than closing it. State this +everywhere the docs land (LAB-646), because "documented" must not be misread as +"fixed": + +- **Namespace ACLs do not isolate non-Python apps that share a tenant.** Two + TS/RS/PHP/interop apps in one tenant both write `default`; a per-API-key + `allowed_namespaces` grant cannot separate them, and per-namespace quotas + cannot scope them. **The only hard isolation boundary for non-Python callers is + a separate tenant or a separate API key** (or, for direct-API callers, the + `nsapi:` write space with its opt-in re-key cost). This is the mitigation to + publish — not "restrict the key to `['default']`", which does not isolate + anything. +- **Cross-tenant separation is single-control.** For unprefixed and interop keys + it rests entirely on auth-layer tenant scoping, with no key-level second layer. + LAB-644 is a live threat to that control and must not be treated as unrelated + hardening. +- **Documentation lag is itself the residual window.** This record sets the + direction; the reader-facing surfaces (the `sdk-feature-matrix.md` namespace + row, the interop-mode / cache-key prose, [protocol#17](https://github.com/cachekit-io/protocol/pull/17)) + land in LAB-646. Until LAB-646 merges, the asymmetry stays undocumented where + readers actually look — so LAB-646 is the gating close-out of the defect, not + this ADR alone. Treat the epic's isolation-gap item as open until LAB-646 lands. +- **Confirm `default` carries a quota ceiling.** If the `default` namespace is + not itself quota-bounded, all non-Python/interop traffic is per-namespace + unmetered — a cost-amplification surface under metered-misses pricing. Verify + during LAB-646/LAB-645; if unbounded, record it as a follow-up. + +## Consequences + +- **protocol docs** (LAB-646): add a **namespace-semantics row** to + [`sdk-feature-matrix.md`](../sdk-feature-matrix.md) — Python: server-side + isolation via `ns:`; direct API: via `nsapi:`; TS/RS/interop: client-side + convention, scoped to `default`. Tighten the interop-mode and cache-key-format + prose to name this asymmetry where a reader meets namespaces. Land + [cachekit-io/protocol#17](https://github.com/cachekit-io/protocol/pull/17) (or + fold its server-contract framing in) so the server story and this SDK story + read as one. +- **stage-2/3 children re-pointed to option 2** (this decision, AC4). The + hardening tickets stand, but as *convention-correctness* work under the + client-side-convention framing, **not** as steps toward an `ns:` rewrite: + - **LAB-641** (cachekit-py auto-mode namespace validation) — unchanged in + intent; Python is the SDK that *does* emit `ns:`, so validating its namespace + segment at decoration time is exactly right. + - **LAB-642** (cachekit-rs `CACHEKIT_NAMESPACE` documented-but-unread; prefix + bypasses key validation) — reframed: the fix is to make RS namespace + behaviour **match the documented client-side-convention semantics** (read it, + validate it, apply it as the `{namespace}:` client prefix), not to emit `ns:`. + - **LAB-645** (SaaS `migrate-namespace` tool: unreachable endpoint, LIKE-vs-GLOB + scoping bleed, `nsapi:`-blind, documented as working) — its defects stand and + must be fixed. Option 2 only *scopes* the tool: it operates on the prefixed + write spaces (`ns:` Python + `nsapi:` direct-API), so being blind to + **unprefixed** TS/RS/interop keys is correct (they are `default`/open, nothing + to migrate). It must **not** be blind to `nsapi:` keys — that blindness is a + real isolation bug in the child, not intended behaviour — and the unreachable + endpoint, the LIKE→GLOB scoping bleed, and the false "working" documentation + are all still in scope. The decision narrows what the tool *should* cover; it + does not bless any of the child's defects. + - **LAB-643** (key truncation can slice the `ns:` segment) — unchanged; a + Python/protocol correctness bug in the one SDK that carries the prefix. + - **LAB-644** (cross-SDK `tenant_id` divergence: RS conflates namespace with + encryption tenant; TS AAD default mismatch) — unchanged in scope; this + decision *reinforces* that namespace (client-side convention) and encryption + tenant (AAD identity) are distinct axes and must not be conflated. +- **stage order** (the LAB-642-vs-LAB-644 question left open on LAB-680): under + option 2, LAB-642 (RS namespace semantics) and LAB-644 (encryption-tenant + divergence) are **independent** — namespace is a client-side convention, tenant + is an AAD/encryption axis — so they need not be serialised against each other. + Keep them in their existing stages; no cross-dependency is introduced by this + decision. + +## Out of scope + +Implementing any of the above (the stage-2/3 children do that); emitting `ns:` / +`nsapi:` prefixes from TS/RS by default (that is rejected option 1); building the +per-key default-namespace override (that is deferred option 3, reopenable as its +own decision). This record produces **one direction** for epic LAB-680 to build +on — nothing more. + +--- + +*Ratification: the epic owner's merge of this PR is the decision. If the owner +prefers option 1 or option 3, that is stated on the PR and the stage-2/3 children +are re-pointed to match before any implementation begins.* From be738ef8ce03410185dfdad2b5729b7390a0b069 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 9 Sep 2026 03:54:01 +1000 Subject: [PATCH 2/7] docs(decisions): attribute interop WARNING cleanup to protocol#17 (LAB-640) --- decisions/namespace-isolation.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/decisions/namespace-isolation.md b/decisions/namespace-isolation.md index 5d1f07d..2c3cc1b 100644 --- a/decisions/namespace-isolation.md +++ b/decisions/namespace-isolation.md @@ -188,8 +188,10 @@ charset whitelist and fall through to `default`/`open` rather than being rejecte by the old auto-mode grammar (verified on `saas` `main`, `apps/cache/src/cache-key-validator.ts` header + fall-through, 2026-09-09). The `interop-mode.md` WARNING that "the deployed validator … would reject -interop-format keys" (`:378-384`) predates saas#91 and is now **stale** — its -cleanup is part of LAB-646, not a blocker on this decision. +interop-format keys" (`:378-384`) predates saas#91 and is now **stale**; its +cleanup (WARNING → NOTE) ships in +[protocol#17](https://github.com/cachekit-io/protocol/pull/17), awaiting the +owner's merge, and is tracked under LAB-646. Neither blocks this decision. Interop is therefore a *within-tenant-shared* space: within a tenant, interop entries are mutually accessible regardless of their `{namespace}` segment. From 6136295a8b2ad0c5aca8eb063e0479c14a08e5f2 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 11 Sep 2026 15:57:15 +1000 Subject: [PATCH 3/7] docs(decisions): address CodeRabbit review on namespace-isolation ADR (LAB-640) - CHANGELOG: "tenant isolation comes from authentication, not key parsing" (was "isolation from authentication", which reversed the relationship). - ADR context: scope the cosmetic-namespace claims to SDK-generated (unprefixed) keys, since a TS/RS app could use direct-API nsapi: keys. - ADR residual risk: stop calling a separate API key / nsapi: a hard data isolation boundary. Grounded in saas apps/cache/src/index.ts: validateNamespaceAccess gates read+write on the namespace (so a prefixed key scoped by allowed_namespaces is real within-tenant isolation), while the ns:/nsapi: write-space split gates writes only (reads open to both classes). The unconditional boundary is a separate tenant; a second API key emitting unprefixed keys shares default and does not isolate. --- CHANGELOG.md | 5 ++-- decisions/namespace-isolation.md | 41 +++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada0a28..b13f44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ All notable changes to the CacheKit Protocol Specification. (TS/RS adopt `ns:` — rejected: key-stability break, billed-miss migration, and it still leaves interop in `default`) and option 3 (per-key default-namespace server override — deferred, reopenable). Interop keys stay - in `default` by the existing spec pin (isolation from authentication, not key - parsing). Documentation-only: no spec key-format change, no server change. + in `default` by the existing spec pin (tenant isolation comes from + authentication, not key parsing). Documentation-only: no spec key-format + change, no server change. **Proposed (accepted on merge)** — the epic owner's merge is the ratification. ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) diff --git a/decisions/namespace-isolation.md b/decisions/namespace-isolation.md index 2c3cc1b..d42527b 100644 --- a/decisions/namespace-isolation.md +++ b/decisions/namespace-isolation.md @@ -58,12 +58,13 @@ interop mode. For all of them the SDK-level "namespace" is a **client-side convention only**; it is invisible to server-side isolation. Concretely, within one tenant: -- Two TS/RS apps cannot be isolated from each other by API-key namespace grants — - all their keys land in `default`. -- An API key restricted to `['default']` can read and write **all** TS/RS/interop - traffic in the tenant. -- Per-namespace quotas cannot scope TS/RS keys — the GLOB pattern never matches an - unprefixed key. +- Two TS/RS apps whose keys are SDK-generated cannot be isolated from each other + by API-key namespace grants — those keys are unprefixed and all land in `default`. +- An API key that can reach `default` (granted it, or unrestricted) can read and + write **all** unprefixed traffic in the tenant — TS/RS SDK-generated keys and + interop keys alike. +- Per-namespace quotas cannot scope unprefixed TS/RS SDK keys — the GLOB pattern + never matches a key with no `ns:` prefix. This asymmetry is real, it is security-relevant, and — critically — **it is undocumented**. Nothing in the SDK docs or @@ -210,14 +211,26 @@ underlying isolation gap as a recorded risk rather than closing it. State this everywhere the docs land (LAB-646), because "documented" must not be misread as "fixed": -- **Namespace ACLs do not isolate non-Python apps that share a tenant.** Two - TS/RS/PHP/interop apps in one tenant both write `default`; a per-API-key - `allowed_namespaces` grant cannot separate them, and per-namespace quotas - cannot scope them. **The only hard isolation boundary for non-Python callers is - a separate tenant or a separate API key** (or, for direct-API callers, the - `nsapi:` write space with its opt-in re-key cost). This is the mitigation to - publish — not "restrict the key to `['default']`", which does not isolate - anything. +- **Unprefixed SDK keys cannot be isolated within a tenant.** Two + TS/RS/PHP/interop apps whose keys are SDK-generated both write `default`; a + per-API-key `allowed_namespaces` grant cannot separate them (dropping `default` + from the grant denies the app its own keys), and per-namespace quotas cannot + scope them. The isolation that *is* available: + - **A separate tenant** — the only unconditional boundary. A distinct auth + identity is a distinct keyspace, so one tenant's `default` is not another's. + - **A namespace-prefixed key scoped by `allowed_namespaces`** — Python's `ns:` + or a direct-API `nsapi:` key carries a real `{namespace}` that the ACL gates + for **both reads and writes** (`validateNamespaceAccess` runs unconditionally, + saas `apps/cache/src/index.ts`), so a key without that namespace granted is + denied. This is real within-tenant isolation — but only for *prefixed* keys; + moving currently-unprefixed traffic onto it is the opt-in re-key (billed-miss) + cost noted above. + + What does **not** isolate, and must not be published as if it does: a second + API key that still emits *unprefixed* keys (both share `default`); and the + `ns:`/`nsapi:` **write-space split**, which blocks cross-class *writes* + (cache-poisoning defence) but leaves *reads* open to both classes — a + write-space control, not read isolation of shared `default` data. - **Cross-tenant separation is single-control.** For unprefixed and interop keys it rests entirely on auth-layer tenant scoping, with no key-level second layer. LAB-644 is a live threat to that control and must not be treated as unrelated From 7baac954b93e3b6c50f748a0d68528778e34c95a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 27 Sep 2026 16:28:01 +1000 Subject: [PATCH 4/7] docs(decisions): refresh namespace-isolation record against main (LAB-640) The Server-Side Requirements section and the feature matrix's namespace-semantics section have landed, so the record no longer describes them as pending. It cites spec sections by anchor instead of line number, so later spec edits cannot leave it stale again. Tightens the security statements to match the published spec: namespace grants isolate a prefixed key only from API keys whose grants are restricted, ns: and nsapi: share one namespace name, legacy ck_live_ keys are exempt from the write-space split, and a quota on default bounds all unprefixed traffic as one pool. --- CHANGELOG.md | 26 ++- decisions/namespace-isolation.md | 290 +++++++++++-------------------- 2 files changed, 115 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b710b88..c61fe9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,19 @@ All notable changes to the CacheKit Protocol Specification. ## [Unreleased] -### Decision — TS/RS namespace isolation is a Python-SDK + `nsapi:` feature (LAB-640) +### Decision — namespace isolation is a Python-SDK + `nsapi:` feature; other SDK namespaces are client-side (LAB-640) - New decision record - [decisions/namespace-isolation.md](decisions/namespace-isolation.md): records - the stage-1 direction for epic LAB-680. Server-side namespace isolation - (`allowed_namespaces` ACL, per-namespace quotas, the `ns:`/`nsapi:` - write-space split) is driven only by the key prefix, and **only cachekit-py - emits `ns:`** — so TS/RS SDK namespaces and interop-mode namespaces are - **client-side conventions**, scoped server-side to `default`/`open`. Chooses - **option 2** (document the asymmetry; no key-format change) over option 1 - (TS/RS adopt `ns:` — rejected: key-stability break, billed-miss migration, - and it still leaves interop in `default`) and option 3 (per-key - default-namespace server override — deferred, reopenable). Interop keys stay - in `default` by the existing spec pin (tenant isolation comes from - authentication, not key parsing). Documentation-only: no spec key-format - change, no server change. - **Proposed (accepted on merge)** — the epic owner's merge is the ratification. + [decisions/namespace-isolation.md](decisions/namespace-isolation.md). + Server-side namespace isolation (per-API-key namespace grants, per-namespace + quotas, the `ns:`/`nsapi:` write-space split) is driven only by the key + prefix, and **only cachekit-py emits `ns:`** — so TS/RS SDK namespaces and + interop-mode namespaces are **client-side conventions**, scoped server-side + to the `default` open write space. Chooses **option 2** (document the + asymmetry; no key-format change) over option 1 (TS/RS adopt `ns:`) and + option 3 (per-key default-namespace override, deferred); see + [the record](decisions/namespace-isolation.md#options). Documentation-only: + no spec key-format change, no server change. ### SaaS API diff --git a/decisions/namespace-isolation.md b/decisions/namespace-isolation.md index d42527b..8ce39c2 100644 --- a/decisions/namespace-isolation.md +++ b/decisions/namespace-isolation.md @@ -6,74 +6,60 @@ | :--- | :--- | | **Status** | Proposed (accepted on merge) | | **Date** | 2026-09-09 | -| **Ticket** | LAB-640 (stage-1 keystone of epic LAB-680), found by the LAB-627 namespace-isolation audit | -| **Precedent** | [cachekit-io/protocol#17](https://github.com/cachekit-io/protocol/pull/17) (author Ray) — documents the *server* contract ("the 7-segment grammar is Python SDK convention, not a server contract"; unprefixed keys are an open write space). This record chooses the *SDK* direction that precedent implies. | -| **Normative spec** | [`spec/cache-key-format.md` → Full Key Structure](../spec/cache-key-format.md#full-key-structure) (the `ns:{namespace}:` prefix) and [`spec/interop-mode.md` → SaaS Considerations](../spec/interop-mode.md#saas-considerations) (interop keys carry no `ns:` prefix). This record owns the rationale and the cross-SDK story; the specs own the rules. | -| **Implementation** | Documentation-only. No key-format change, no server change, no SDK code change. Follow-ups: the [feature matrix](../sdk-feature-matrix.md) namespace-semantics row (LAB-646) and the stage-2/3 hardening children (LAB-641/642/645, LAB-643/644) are re-pointed to this direction, not to a key-format rewrite. | +| **Ticket** | LAB-640 | +| **Precedent** | [cachekit-io/protocol#17](https://github.com/cachekit-io/protocol/pull/17) (merged) — documents the *server* contract: the 7-segment grammar is Python SDK convention, not a server contract, and unprefixed keys are an open write space. This record chooses the *SDK* direction that contract implies. | +| **Normative spec** | [`spec/cache-key-format.md` → Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements) (what the server enforces, including the `default` namespace and the write-space split), [→ Full Key Structure](../spec/cache-key-format.md#full-key-structure) (the `ns:{namespace}:` prefix), [`spec/saas-api.md` → Authentication](../spec/saas-api.md#authentication) (key classes and namespace grants), and [`spec/interop-mode.md` → SaaS Considerations](../spec/interop-mode.md#saas-considerations) (interop keys carry no `ns:` prefix). This record owns the rationale and the cross-SDK story; the specs own the rules. | +| **Implementation** | Documentation-only. No key-format change, no server change, no SDK code change. | --- ## Context -Namespace-based access control on the CachekitIO SaaS backend — the -`allowed_namespaces` ACL, per-namespace quotas, and the `ns:`/`nsapi:` -intra-tenant write-space split — is driven **entirely by the key prefix**. The -server treats every cache key as an opaque string and parses only the leading -`ns:{namespace}:` / `nsapi:{namespace}:` segment; a key without that prefix is -scoped to `namespace='default'`, `keyClass='open'`, writable by every key class -(verified on `saas` `main`, 2026-09-09): - -- `apps/cache/src/cache-key-validator.ts:118-120` — the fall-through return for - a non-prefixed key: `{ ok: true, key, namespace: 'default', keyClass: 'open' }`, - with the code comment naming the callers by SDK: *"Non-prefixed key (TS/Rust - SDK format, interop mode, bare hash): scoped to the `default` namespace."* -- `apps/cache/src/index.ts:726-762` — *"The SaaS treats keys as opaque strings; - only the ns:/nsapi: namespace prefix is load-bearing server-side (tenant - namespace isolation, quotas)."* The write-space `403`s fire only when - `keyClass` is `sdk` or `api`, i.e. only on prefixed keys. +Namespace-based access control on the CachekitIO SaaS backend — per-API-key +namespace grants, per-namespace quotas, and the `ns:`/`nsapi:` intra-tenant +write-space split — is driven **entirely by the key prefix**. The server treats +every cache key as an opaque string and parses only a leading +`ns:{namespace}:` / `nsapi:{namespace}:` segment, whoever wrote the key. A key +with neither prefix is scoped to the `default` namespace, an **open** write +space that any key class may write +([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements)). **Only cachekit-py emits the `ns:` prefix.** Its auto-mode key generator prepends `ns:{namespace}:` when a namespace is set -([`spec/cache-key-format.md:36,41`](../spec/cache-key-format.md#full-key-structure)). -The other SDKs do not (verified in the LAB-3179 grooming sweep, 2026-09-09, on -each repo's `main`): - -- **cachekit-ts** emits `{namespace}:{hex}` with no `ns:` prefix - (`packages/cachekit/src/serialization/key-generator.ts:21,44`); its own - examples even put colons *inside* the namespace (`:23,:30`). -- **cachekit-rs** `get`/`set` take caller keys and prepend `{namespace}:` when - `.namespace()` is set — still no `ns:` (`crates/cachekit/src/client.rs:246-250`). -- **Interop mode** keys are `{namespace}:{operation}:{args_hash}`, spec-pinned to - **no `ns:` prefix** - ([`spec/interop-mode.md:374-376`](../spec/interop-mode.md#saas-considerations)): - *"the `{namespace}` segment is an SDK-level convention, not a SaaS routing - element (tenant isolation comes from authentication, not key parsing)."* - -### Consequence — the thing this record settles +([Full Key Structure](../spec/cache-key-format.md#full-key-structure)). No other +SDK's key generation emits an `ns:` token; the feature matrix's +[namespace-semantics section](../sdk-feature-matrix.md#namespace-semantics-per-sdk-divergence) +records each SDK's key shape with source citations. **Interop mode** keys are +`{namespace}:{operation}:{args_hash}`, spec-pinned to **no `ns:` prefix** +([SaaS Considerations](../spec/interop-mode.md#saas-considerations)): *"the +`{namespace}` segment is an SDK-level convention, not a SaaS routing element +(tenant isolation comes from authentication, not key parsing)."* + +### Impact The rule is not specific to TS and RS: **because only cachekit-py emits `ns:`, every non-Python SDK is affected identically** — TS, RS, and any other SDK in the fleet (the [feature matrix](../sdk-feature-matrix.md) also lists PHP), plus interop mode. For all of them the SDK-level "namespace" is a **client-side -convention only**; it is invisible to server-side isolation. Concretely, within -one tenant: +convention only**: the keys those SDKs generate are unprefixed, so server-side +isolation cannot see the namespace. Concretely, within one tenant: - Two TS/RS apps whose keys are SDK-generated cannot be isolated from each other by API-key namespace grants — those keys are unprefixed and all land in `default`. - An API key that can reach `default` (granted it, or unrestricted) can read and write **all** unprefixed traffic in the tenant — TS/RS SDK-generated keys and interop keys alike. -- Per-namespace quotas cannot scope unprefixed TS/RS SDK keys — the GLOB pattern - never matches a key with no `ns:` prefix. - -This asymmetry is real, it is security-relevant, and — critically — **it is -undocumented**. Nothing in the SDK docs or -[`sdk-feature-matrix.md`](../sdk-feature-matrix.md) tells a reader that only -Python participates in layer-2 (within-tenant) isolation. The bug is not that the -server behaves this way; the bug is that a reader cannot find out that it does. -This record closes the **documentation** defect (the silence); it does **not** -close the isolation gap itself — that gap is accepted as a recorded residual -risk (see [Residual risk](#residual-risk-accepted-not-closed) below). +- A per-namespace quota on `default` bounds all unprefixed traffic as **one + pool**; no quota can divide that pool between apps. + +This asymmetry is security-relevant, and when this record was drafted it was +undocumented. The bug was not that the server behaves this way; it was that a +reader could not find out that it does. The specs now state it +([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements), +"Default namespace"). This record makes it the intended contract rather than a +gap awaiting a key-format fix. The isolation gap itself is **not** closed — it +is accepted as a recorded residual risk (see +[Residual risk](#residual-risk-accepted-not-closed) below). ## Options @@ -84,11 +70,10 @@ Three directions were considered. The decision is **option 2**. Make every SDK emit `ns:{namespace}:...` so the server's isolation applies uniformly. Rejected on three grounds, in priority order: -1. **It is a cache-key-format change**, so it trips the crypto/protocol - expert-panel gate before any line ships, and it must be specced as a - normative key-format revision across four SDKs — the largest possible blast - radius for the smallest possible win here (the win is available for free under - option 2's documentation + existing `nsapi:` path). +1. **It is a cache-key-format change.** It must be specced as a normative + key-format revision across every SDK and reviewed as a protocol change before + any line ships — the largest possible blast radius for the smallest possible + win here. 2. **It is a key-stability break.** Changing the generated key for existing deployments orphans every entry already written under the old format. Under the metered-misses SaaS pricing model, those orphans convert directly into @@ -110,16 +95,16 @@ isolation is delivered by: - the **direct HTTP API** using the `nsapi:{namespace}:{key}` write space, and that **TS/RS SDK namespaces, and interop-mode namespaces, are client-side -key-organisation conventions with no server-side isolation, quota, or ACL -effect** — everything they write is `default`/`open`. This is the direction the -server contract already documents in the owner's own words -([cachekit-io/protocol#17](https://github.com/cachekit-io/protocol/pull/17)); this -record makes it the recorded SDK-level decision and forces the docs to say it. +key-organisation conventions with no per-namespace isolation, quota, or ACL +effect** — the keys they generate all land in one `default` open write space. +This is the direction the server contract already documents +([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements)); +this record makes it the recorded SDK-level decision. Chosen because it is the honest description of the shipped system, it carries **no key-stability break and no billed-miss migration**, it needs **no server -work**, and it closes the actual defect (the silent, undocumented asymmetry) -rather than papering it with a migration whose interop hole would keep the +work**, and it fixes the actual defect (the silent, undocumented asymmetry) +rather than papering over it with a migration whose interop hole would keep the asymmetry anyway. ### 3. Server-side per-API-key default-namespace override — rejected (deferred, not foreclosed) @@ -140,15 +125,16 @@ of record because: It is recorded as **deferred, not foreclosed**: if a customer later needs server-side isolation for TS/RS without an SDK change, option 3 is the path to -reopen — as a new decision, with its own gate. +reopen — as a new decision, with its own review. ## Decision **Adopt option 2.** Server-side namespace isolation is a **Python-SDK + direct-`nsapi:`-API** feature. TS/RS SDK namespaces and interop-mode namespaces -are **client-side conventions** with no server-side isolation, quota, or ACL -effect; keys without an `ns:`/`nsapi:` prefix are scoped to `default`/`open` and -are mutually readable and writable within a tenant. No cache-key format changes. +are **client-side conventions** with no per-namespace isolation, quota, or ACL +effect; keys without an `ns:`/`nsapi:` prefix are scoped to the `default` open +write space and are mutually readable and writable within a tenant. No cache-key +format changes. Points that are decision, not mechanism: @@ -157,148 +143,80 @@ Points that are decision, not mechanism: risk). - **`nsapi:` is the isolation path for *direct-API* writers — not a drop-in for the SDKs.** A caller that needs true server-side namespace isolation without - Python uses the `nsapi:{namespace}:{key}` write space explicitly. Two caveats a - reader must not miss: (a) **no SDK emits `nsapi:`** — the TS/RS keygens produce - `{namespace}:{...}` and interop is grammar-pinned, so reaching `nsapi:` means - hand-crafting keys against the raw HTTP API, bypassing SDK key generation; and - (b) `nsapi:` is the **`api`-class** write space, distinct from Python's - **`sdk`-class** `ns:` space — they do not share a namespace for *writes* (the - write-space `403`s enforce the split; reads are open to both within a tenant). - Adopting `nsapi:` for keys currently written unprefixed **re-keys** them, - orphaning the existing `default`-scoped entries into billed misses — the same - cost class as option 1, but **scoped and opt-in** (one caller's keys) rather - than a fleet-wide format break. Emitting `ns:`/`nsapi:` from the SDKs by default - is out of scope here (that is option 1, rejected). -- **No key-format change ⇒ the crypto/protocol expert-panel gate does not gate - implementation** here. This record still passes the expert-panel *design* gate - before merge (Size M, `security`); AC3 is satisfied vacuously — no key-format - change means no orphaned-entry/billed-miss migration to schedule. - -### The interop story (required by AC2) + Python uses the `nsapi:{namespace}:{key}` write space explicitly. Three caveats + a reader must not miss: + - **No SDK's key generation emits `nsapi:`.** Reaching it means hand-crafting + keys — through the raw HTTP API, or through an SDK's caller-supplied-key + `get`/`set` — with a direct (`ck_api_`) API key. + - **`ns:` and `nsapi:` are separate write spaces under one namespace name.** + `ns:users:` and `nsapi:users:` are the same `users` namespace for grants and + quotas; the write-space split only decides which key class may *write* each + prefix, reads are open to both, and legacy `ck_live_` keys are exempt from + the split ([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements)). + - **Adopting `nsapi:` re-keys.** Moving keys currently written unprefixed onto + `nsapi:` orphans the existing `default`-scoped entries into billed misses — + the same cost class as option 1, but **scoped and opt-in** (one caller's + keys) rather than a fleet-wide format break. + +### The interop story Interop-mode keys stay in `default` server-side, and this is deliberate. They are spec-pinned to carry **no `ns:` prefix** -([`spec/interop-mode.md:374-376`](../spec/interop-mode.md#saas-considerations)): -the `{namespace}` segment is a cross-SDK key-organisation convention, and tenant +([SaaS Considerations](../spec/interop-mode.md#saas-considerations)): the +`{namespace}` segment is a cross-SDK key-organisation convention, and tenant isolation for interop comes from **authentication**, not key parsing. This -decision does **not** change that pin. - -That interop keys are *accepted at all* by the SaaS is now live: the CachekitIO -validator was shrunk to security-only checks (saas#91), so interop keys pass the -charset whitelist and fall through to `default`/`open` rather than being rejected -by the old auto-mode grammar (verified on `saas` `main`, -`apps/cache/src/cache-key-validator.ts` header + fall-through, 2026-09-09). The -`interop-mode.md` WARNING that "the deployed validator … would reject -interop-format keys" (`:378-384`) predates saas#91 and is now **stale**; its -cleanup (WARNING → NOTE) ships in -[protocol#17](https://github.com/cachekit-io/protocol/pull/17), awaiting the -owner's merge, and is tracked under LAB-646. Neither blocks this decision. +decision does **not** change that pin. The SaaS validator is security-only, so +interop keys are accepted and scope to `default` +([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements)). Interop is therefore a *within-tenant-shared* space: within a tenant, interop entries are mutually accessible regardless of their `{namespace}` segment. -Cross-tenant separation holds **only insofar as the auth-layer tenant scoping is -correct** — it is the *single* control (interop keys carry no tenant routing, so -there is no second layer behind it), and sibling ticket LAB-644 tracks a live -threat to exactly that assumption (RS conflating namespace with encryption -tenant; TS AAD default mismatch). Option 1 would not have improved any of this -(interop cannot take an `ns:` prefix without breaking the cross-SDK grammar), -which is the third reason it was rejected. +Option 1 would not have improved this (interop cannot take an `ns:` prefix +without breaking the cross-SDK grammar), which is the third reason it was +rejected. ## Residual risk (accepted, not closed) -Option 2 closes the **documentation** defect. It explicitly **accepts** the -underlying isolation gap as a recorded risk rather than closing it. State this -everywhere the docs land (LAB-646), because "documented" must not be misread as -"fixed": +Option 2 fixes the **documentation** defect. It explicitly **accepts** the +underlying isolation gap as a recorded risk rather than closing it. "Documented" +must not be read as "fixed": - **Unprefixed SDK keys cannot be isolated within a tenant.** Two - TS/RS/PHP/interop apps whose keys are SDK-generated both write `default`; a - per-API-key `allowed_namespaces` grant cannot separate them (dropping `default` - from the grant denies the app its own keys), and per-namespace quotas cannot - scope them. The isolation that *is* available: - - **A separate tenant** — the only unconditional boundary. A distinct auth - identity is a distinct keyspace, so one tenant's `default` is not another's. - - **A namespace-prefixed key scoped by `allowed_namespaces`** — Python's `ns:` - or a direct-API `nsapi:` key carries a real `{namespace}` that the ACL gates - for **both reads and writes** (`validateNamespaceAccess` runs unconditionally, - saas `apps/cache/src/index.ts`), so a key without that namespace granted is - denied. This is real within-tenant isolation — but only for *prefixed* keys; - moving currently-unprefixed traffic onto it is the opt-in re-key (billed-miss) + TS/RS/interop apps whose keys are SDK-generated both write `default`; a + per-API-key namespace grant cannot separate them (dropping `default` from the + grant denies the app its own keys), and a quota can bound them only as one + shared pool. The isolation that *is* available: + - **A separate tenant** (not merely a separate API key) — the only boundary + that does not depend on namespace grants. One tenant's `default` is not + another's. + - **A namespace-prefixed key under restricted grants** — Python's `ns:` or a + direct-API `nsapi:` key carries a real `{namespace}`, and per-key namespace + grants gate **both reads and writes** on it. This isolates the namespace + only from API keys whose grants are restricted: an unrestricted key in the + same tenant reads and writes every namespace, prefixed or not + ([Authentication](../spec/saas-api.md#authentication)). Moving + currently-unprefixed traffic onto a prefix is the opt-in re-key (billed-miss) cost noted above. What does **not** isolate, and must not be published as if it does: a second API key that still emits *unprefixed* keys (both share `default`); and the `ns:`/`nsapi:` **write-space split**, which blocks cross-class *writes* - (cache-poisoning defence) but leaves *reads* open to both classes — a - write-space control, not read isolation of shared `default` data. + (cache-poisoning defence, with the legacy-key exemption above) but leaves + *reads* open to both classes — a write-space control, not read isolation. - **Cross-tenant separation is single-control.** For unprefixed and interop keys - it rests entirely on auth-layer tenant scoping, with no key-level second layer. - LAB-644 is a live threat to that control and must not be treated as unrelated - hardening. -- **Documentation lag is itself the residual window.** This record sets the - direction; the reader-facing surfaces (the `sdk-feature-matrix.md` namespace - row, the interop-mode / cache-key prose, [protocol#17](https://github.com/cachekit-io/protocol/pull/17)) - land in LAB-646. Until LAB-646 merges, the asymmetry stays undocumented where - readers actually look — so LAB-646 is the gating close-out of the defect, not - this ADR alone. Treat the epic's isolation-gap item as open until LAB-646 lands. -- **Confirm `default` carries a quota ceiling.** If the `default` namespace is - not itself quota-bounded, all non-Python/interop traffic is per-namespace - unmetered — a cost-amplification surface under metered-misses pricing. Verify - during LAB-646/LAB-645; if unbounded, record it as a follow-up. + it rests entirely on authentication-layer tenant scoping, with no key-level + second layer. Any SDK or server change that touches how the tenant is derived + must be treated as touching the only boundary these keys have. ## Consequences -- **protocol docs** (LAB-646): add a **namespace-semantics row** to - [`sdk-feature-matrix.md`](../sdk-feature-matrix.md) — Python: server-side - isolation via `ns:`; direct API: via `nsapi:`; TS/RS/interop: client-side - convention, scoped to `default`. Tighten the interop-mode and cache-key-format - prose to name this asymmetry where a reader meets namespaces. Land - [cachekit-io/protocol#17](https://github.com/cachekit-io/protocol/pull/17) (or - fold its server-contract framing in) so the server story and this SDK story - read as one. -- **stage-2/3 children re-pointed to option 2** (this decision, AC4). The - hardening tickets stand, but as *convention-correctness* work under the - client-side-convention framing, **not** as steps toward an `ns:` rewrite: - - **LAB-641** (cachekit-py auto-mode namespace validation) — unchanged in - intent; Python is the SDK that *does* emit `ns:`, so validating its namespace - segment at decoration time is exactly right. - - **LAB-642** (cachekit-rs `CACHEKIT_NAMESPACE` documented-but-unread; prefix - bypasses key validation) — reframed: the fix is to make RS namespace - behaviour **match the documented client-side-convention semantics** (read it, - validate it, apply it as the `{namespace}:` client prefix), not to emit `ns:`. - - **LAB-645** (SaaS `migrate-namespace` tool: unreachable endpoint, LIKE-vs-GLOB - scoping bleed, `nsapi:`-blind, documented as working) — its defects stand and - must be fixed. Option 2 only *scopes* the tool: it operates on the prefixed - write spaces (`ns:` Python + `nsapi:` direct-API), so being blind to - **unprefixed** TS/RS/interop keys is correct (they are `default`/open, nothing - to migrate). It must **not** be blind to `nsapi:` keys — that blindness is a - real isolation bug in the child, not intended behaviour — and the unreachable - endpoint, the LIKE→GLOB scoping bleed, and the false "working" documentation - are all still in scope. The decision narrows what the tool *should* cover; it - does not bless any of the child's defects. - - **LAB-643** (key truncation can slice the `ns:` segment) — unchanged; a - Python/protocol correctness bug in the one SDK that carries the prefix. - - **LAB-644** (cross-SDK `tenant_id` divergence: RS conflates namespace with - encryption tenant; TS AAD default mismatch) — unchanged in scope; this - decision *reinforces* that namespace (client-side convention) and encryption - tenant (AAD identity) are distinct axes and must not be conflated. -- **stage order** (the LAB-642-vs-LAB-644 question left open on LAB-680): under - option 2, LAB-642 (RS namespace semantics) and LAB-644 (encryption-tenant - divergence) are **independent** — namespace is a client-side convention, tenant - is an AAD/encryption axis — so they need not be serialised against each other. - Keep them in their existing stages; no cross-dependency is introduced by this - decision. +- **Docs.** The specs listed under *Normative spec* above are the reader-facing + contract for this decision. +- **SDK namespace handling stays a client-side convention.** Namespace work in + the SDKs is convention-correctness work, not a step toward an `ns:` rewrite. ## Out of scope -Implementing any of the above (the stage-2/3 children do that); emitting `ns:` / -`nsapi:` prefixes from TS/RS by default (that is rejected option 1); building the -per-key default-namespace override (that is deferred option 3, reopenable as its -own decision). This record produces **one direction** for epic LAB-680 to build -on — nothing more. - ---- - -*Ratification: the epic owner's merge of this PR is the decision. If the owner -prefers option 1 or option 3, that is stated on the PR and the stage-2/3 children -are re-pointed to match before any implementation begins.* +Implementing any of the above; emitting `ns:` / `nsapi:` prefixes from TS/RS by +default (that is rejected option 1); building the per-key default-namespace +override (that is deferred option 3, reopenable as its own decision). From df0c6b1d0a5a2bc9cf14236abf8ebd5987400eff Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 28 Sep 2026 03:25:01 +1000 Subject: [PATCH 5/7] docs(decisions): tighten namespace-isolation claims (LAB-640) - Only cachekit-py adds `ns:`, but TS/RS do not reserve the prefixes: a namespace or caller-supplied key that begins `ns:{name}:` is scoped to `{name}` (TS always; RS only on a client without `.namespace()`). - A prefix named `default` gets no grant isolation: `ns:default:` and `nsapi:default:` share the grant of every unprefixed key. - Cross-tenant separation has no key-level layer for any key, prefixed or not; scoped the claim to server-side separation. - Hand-crafted `ns:` keys via `ck_sdk_`, `nsapi:` writes via `ck_live_`, and the read vs write scope of an unrestricted key. - Interop: the segment grammar does not reserve `ns` / `nsapi`. - Define "TS/RS" once, drop duplicated passages, index the record in README. --- CHANGELOG.md | 8 +-- README.md | 1 + decisions/namespace-isolation.md | 91 +++++++++++++++++++------------- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1fc144..e80b12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ All notable changes to the CacheKit Protocol Specification. [decisions/namespace-isolation.md](decisions/namespace-isolation.md). Server-side namespace isolation (per-API-key namespace grants, per-namespace quotas, the `ns:`/`nsapi:` write-space split) is driven only by the key - prefix, and **only cachekit-py emits `ns:`** — so TS/RS SDK namespaces and - interop-mode namespaces are **client-side conventions**, scoped server-side - to the `default` open write space. Chooses **option 2** (document the + prefix, and namespace grants isolate only a namespace other than `default`. + **Only cachekit-py adds `ns:`** (in auto mode, when a namespace is set), so + TS/RS SDK namespaces + and interop-mode namespaces are **client-side conventions**, scoped + server-side to the `default` open write space. Chooses **option 2** (document the asymmetry; no key-format change) over option 1 (TS/RS adopt `ns:`) and option 3 (per-key default-namespace override, deferred); see [the record](decisions/namespace-isolation.md#options). Documentation-only: diff --git a/README.md b/README.md index 6a42119..2fd2f83 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ layer's own store/retrieve flows are specified in | [spec/intent-presets.md](spec/intent-presets.md) | Intent-preset contract — canonical `minimal` / `production` / `secure` / `io` defaults: TTL, L1 and integrity posture, reliability floor, encryption activation and key input, `io` credentials *(normative; per-SDK conformance and alignment tickets inside)* | | [sdk-feature-matrix.md](sdk-feature-matrix.md) | Feature parity tracking across Python, Rust, TypeScript, and PHP SDKs | | [decisions/key-rotation.md](decisions/key-rotation.md) | Decision records — master-key rotation via client-side keyring (rationale, rejected options, operator runbooks) | +| [decisions/namespace-isolation.md](decisions/namespace-isolation.md) | Decision record — server-side namespace isolation is a Python-SDK + direct-API (`nsapi:`) feature; other SDKs' namespaces are client-side conventions (rationale, rejected options, residual risk) | --- diff --git a/decisions/namespace-isolation.md b/decisions/namespace-isolation.md index 8ce39c2..e565ea7 100644 --- a/decisions/namespace-isolation.md +++ b/decisions/namespace-isolation.md @@ -24,12 +24,19 @@ with neither prefix is scoped to the `default` namespace, an **open** write space that any key class may write ([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements)). -**Only cachekit-py emits the `ns:` prefix.** Its auto-mode key generator +**Only cachekit-py adds the `ns:` prefix.** Its auto-mode key generator prepends `ns:{namespace}:` when a namespace is set ([Full Key Structure](../spec/cache-key-format.md#full-key-structure)). No other -SDK's key generation emits an `ns:` token; the feature matrix's +SDK's key generation adds an `ns:` token of its own; the feature matrix's [namespace-semantics section](../sdk-feature-matrix.md#namespace-semantics-per-sdk-divergence) -records each SDK's key shape with source citations. **Interop mode** keys are +records each SDK's key shape with source citations. The server cannot tell who +built a key, though, and TS and RS do not reserve the prefixes: a TS/RS +namespace such as `ns:team`, or a caller-supplied key such as `ns:team:x` (TS +always; RS only on a client built without `.namespace()`), reaches the server +as an `ns:` key scoped to namespace `team`, not `default`. That is hand-crafting +a prefix (see the caveats under [Decision](#decision)), not SDK namespace +behaviour; where this record says TS/RS or interop keys land in `default`, it +means keys that do not begin with a prefix. **Interop mode** keys are `{namespace}:{operation}:{args_hash}`, spec-pinned to **no `ns:` prefix** ([SaaS Considerations](../spec/interop-mode.md#saas-considerations)): *"the `{namespace}` segment is an SDK-level convention, not a SaaS routing element @@ -37,11 +44,12 @@ records each SDK's key shape with source citations. **Interop mode** keys are ### Impact -The rule is not specific to TS and RS: **because only cachekit-py emits `ns:`, +The rule is not specific to TS and RS: **because only cachekit-py adds `ns:`, every non-Python SDK is affected identically** — TS, RS, and any other SDK in the fleet (the [feature matrix](../sdk-feature-matrix.md) also lists PHP), plus -interop mode. For all of them the SDK-level "namespace" is a **client-side -convention only**: the keys those SDKs generate are unprefixed, so server-side +interop mode; this record writes "TS/RS" for the whole set. For all of them +the SDK-level "namespace" is a **client-side convention only**: the keys those +SDKs generate are unprefixed, so server-side isolation cannot see the namespace. Concretely, within one tenant: - Two TS/RS apps whose keys are SDK-generated cannot be isolated from each other @@ -56,10 +64,8 @@ This asymmetry is security-relevant, and when this record was drafted it was undocumented. The bug was not that the server behaves this way; it was that a reader could not find out that it does. The specs now state it ([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements), -"Default namespace"). This record makes it the intended contract rather than a -gap awaiting a key-format fix. The isolation gap itself is **not** closed — it -is accepted as a recorded residual risk (see -[Residual risk](#residual-risk-accepted-not-closed) below). +"Default namespace"), and this record makes it the intended contract; the gap +itself stays open as an accepted [residual risk](#residual-risk-accepted-not-closed). ## Options @@ -130,29 +136,35 @@ reopen — as a new decision, with its own review. ## Decision **Adopt option 2.** Server-side namespace isolation is a **Python-SDK + -direct-`nsapi:`-API** feature. TS/RS SDK namespaces and interop-mode namespaces -are **client-side conventions** with no per-namespace isolation, quota, or ACL -effect; keys without an `ns:`/`nsapi:` prefix are scoped to the `default` open -write space and are mutually readable and writable within a tenant. No cache-key -format changes. +direct-`nsapi:`-API** feature, and only for a namespace other than `default`. +TS/RS SDK namespaces and interop-mode namespaces are **client-side +conventions** with no per-namespace isolation, quota, or ACL effect; keys +without an `ns:`/`nsapi:` prefix are scoped to the `default` open write space +and are mutually readable and writable within a tenant. No cache-key format +changes. Points that are decision, not mechanism: -- **The asymmetry is documented, not removed.** The fix makes the shipped - behaviour discoverable; the isolation gap itself is accepted (see Residual - risk). - **`nsapi:` is the isolation path for *direct-API* writers — not a drop-in for the SDKs.** A caller that needs true server-side namespace isolation without - Python uses the `nsapi:{namespace}:{key}` write space explicitly. Three caveats + Python uses the `nsapi:{namespace}:{key}` write space explicitly. Four caveats a reader must not miss: - - **No SDK's key generation emits `nsapi:`.** Reaching it means hand-crafting - keys — through the raw HTTP API, or through an SDK's caller-supplied-key - `get`/`set` — with a direct (`ck_api_`) API key. + - **No SDK adds `nsapi:` of its own.** Writing it means hand-crafting the + prefix — through the raw HTTP API, an SDK's caller-supplied-key `get`/`set`, + or a TS/RS namespace value — with a direct (`ck_api_`) or legacy `ck_live_` + API key. A hand-crafted `ns:{namespace}:` key, such as one sent through the + TS SDK's caller-supplied-key `get`/`set` with a `ck_sdk_` key, gets the same + server-side scoping: the server checks the prefix, not which SDK built it. - **`ns:` and `nsapi:` are separate write spaces under one namespace name.** `ns:users:` and `nsapi:users:` are the same `users` namespace for grants and quotas; the write-space split only decides which key class may *write* each prefix, reads are open to both, and legacy `ck_live_` keys are exempt from the split ([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements)). + - **A prefix named `default` gets no grant isolation.** `ns:default:` and + `nsapi:default:` keys are gated by the same `default` grant as every + unprefixed key, so a grant cannot separate them from the tenant's + unprefixed TS/RS and interop traffic. A Python app with no namespace set, + or with `namespace="default"`, gets no namespace-grant isolation. - **Adopting `nsapi:` re-keys.** Moving keys currently written unprefixed onto `nsapi:` orphans the existing `default`-scoped entries into billed misses — the same cost class as option 1, but **scoped and opt-in** (one caller's @@ -165,15 +177,15 @@ spec-pinned to carry **no `ns:` prefix** ([SaaS Considerations](../spec/interop-mode.md#saas-considerations)): the `{namespace}` segment is a cross-SDK key-organisation convention, and tenant isolation for interop comes from **authentication**, not key parsing. This -decision does **not** change that pin. The SaaS validator is security-only, so -interop keys are accepted and scope to `default` -([Server-Side Requirements](../spec/cache-key-format.md#server-side-requirements)). +decision does **not** change that pin. One edge: the interop +[segment grammar](../spec/interop-mode.md#segment-grammar) rejects `:` but does +not reserve `ns` or `nsapi`, so an interop namespace named either one yields a +key the server parses as prefixed, not `default`: it is scoped to a namespace +named after the operation, or rejected with `400` when the operation contains +`.`. Neither name is safe as an interop namespace. Interop is therefore a *within-tenant-shared* space: within a tenant, interop entries are mutually accessible regardless of their `{namespace}` segment. -Option 1 would not have improved this (interop cannot take an `ns:` prefix -without breaking the cross-SDK grammar), which is the third reason it was -rejected. ## Residual risk (accepted, not closed) @@ -190,10 +202,12 @@ must not be read as "fixed": that does not depend on namespace grants. One tenant's `default` is not another's. - **A namespace-prefixed key under restricted grants** — Python's `ns:` or a - direct-API `nsapi:` key carries a real `{namespace}`, and per-key namespace - grants gate **both reads and writes** on it. This isolates the namespace - only from API keys whose grants are restricted: an unrestricted key in the - same tenant reads and writes every namespace, prefixed or not + direct-API `nsapi:` key carries a real `{namespace}` for any name other than + `default`, and per-key namespace grants gate **both reads and writes** on it. + This isolates the namespace only from API keys whose grants are restricted: + an unrestricted key in the same tenant reads every namespace, prefixed or + not, and writes every namespace within its key class's write spaces (a + legacy `ck_live_` key: all of them) ([Authentication](../spec/saas-api.md#authentication)). Moving currently-unprefixed traffic onto a prefix is the opt-in re-key (billed-miss) cost noted above. @@ -203,15 +217,16 @@ must not be read as "fixed": `ns:`/`nsapi:` **write-space split**, which blocks cross-class *writes* (cache-poisoning defence, with the legacy-key exemption above) but leaves *reads* open to both classes — a write-space control, not read isolation. -- **Cross-tenant separation is single-control.** For unprefixed and interop keys - it rests entirely on authentication-layer tenant scoping, with no key-level - second layer. Any SDK or server change that touches how the tenant is derived - must be treated as touching the only boundary these keys have. +- **Cross-tenant separation is single-control.** For every key, prefixed or + not, server-side separation rests on authentication-layer tenant scoping, + with no key-level second layer: no cache key carries a tenant component + ([Authentication](../spec/saas-api.md#authentication)), and a namespace is + never a tenant boundary. Any SDK or server change that touches how the + tenant is derived must be treated as touching the only server-side boundary + every key has. ## Consequences -- **Docs.** The specs listed under *Normative spec* above are the reader-facing - contract for this decision. - **SDK namespace handling stays a client-side convention.** Namespace work in the SDKs is convention-correctness work, not a step toward an `ns:` rewrite. From b28a03c8c0ed5a70c33a8d051178ed51119ecd53 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 28 Sep 2026 22:13:54 +1000 Subject: [PATCH 6/7] docs(decisions): scope the SDK claim to documented TS, RS and interop paths (LAB-640) The feature matrix records no PHP namespace implementation, so the record no longer asserts that PHP behaves like TS and RS. The rule stays general: any SDK whose keys carry no prefix lands in default. --- decisions/namespace-isolation.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/decisions/namespace-isolation.md b/decisions/namespace-isolation.md index e565ea7..08c0753 100644 --- a/decisions/namespace-isolation.md +++ b/decisions/namespace-isolation.md @@ -44,10 +44,10 @@ means keys that do not begin with a prefix. **Interop mode** keys are ### Impact -The rule is not specific to TS and RS: **because only cachekit-py adds `ns:`, -every non-Python SDK is affected identically** — TS, RS, and any other SDK in -the fleet (the [feature matrix](../sdk-feature-matrix.md) also lists PHP), plus -interop mode; this record writes "TS/RS" for the whole set. For all of them +The rule is not specific to TS and RS: **because the server keys isolation on +the prefix and only cachekit-py adds `ns:`, the documented TS, RS and interop +paths are affected identically**, and so is any future SDK whose keys carry no +prefix; this record writes "TS/RS" for that set. For all of them the SDK-level "namespace" is a **client-side convention only**: the keys those SDKs generate are unprefixed, so server-side isolation cannot see the namespace. Concretely, within one tenant: From 7e9be22ef50adc9e97f445b0dfce85777ae4b725 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 29 Sep 2026 11:03:10 +1000 Subject: [PATCH 7/7] docs(decisions): align interop edge with the reserved ns/nsapi namespaces (LAB-640) The interop segment grammar now reserves ns and nsapi. The record no longer says the grammar leaves them open, and points to the feature matrix for which SDK releases enforce the reservation. --- decisions/namespace-isolation.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/decisions/namespace-isolation.md b/decisions/namespace-isolation.md index 08c0753..001ffb8 100644 --- a/decisions/namespace-isolation.md +++ b/decisions/namespace-isolation.md @@ -177,12 +177,14 @@ spec-pinned to carry **no `ns:` prefix** ([SaaS Considerations](../spec/interop-mode.md#saas-considerations)): the `{namespace}` segment is a cross-SDK key-organisation convention, and tenant isolation for interop comes from **authentication**, not key parsing. This -decision does **not** change that pin. One edge: the interop -[segment grammar](../spec/interop-mode.md#segment-grammar) rejects `:` but does -not reserve `ns` or `nsapi`, so an interop namespace named either one yields a -key the server parses as prefixed, not `default`: it is scoped to a namespace -named after the operation, or rejected with `400` when the operation contains -`.`. Neither name is safe as an interop namespace. +decision does **not** change that pin. The interop +[segment grammar](../spec/interop-mode.md#segment-grammar) reserves the +namespaces `ns` and `nsapi` for exactly this reason: an interop key starting +`ns:` or `nsapi:` would be parsed as prefixed, scoped to a namespace named after +the operation (or rejected with `400` when the operation contains `.`), rather +than `default`. Which SDK releases enforce the reservation is tracked in the +feature matrix's [Compliance Status](../sdk-feature-matrix.md#compliance-status) +"Test vectors in CI" row. Interop is therefore a *within-tenant-shared* space: within a tenant, interop entries are mutually accessible regardless of their `{namespace}` segment.