From 70e8ac83eea876dc8f75e63069431145f0cef803 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 9 Sep 2026 03:43:27 +1000 Subject: [PATCH 1/3] docs(matrix): add namespace-semantics row (LAB-646) Feature matrix had no row for namespace semantics despite per-SDK divergence: py emits a distinct ns: segment, ts/rs emit a bare namespace-colon-key/hash with no ns: token, and unprefixed keys of any origin scope to the SaaS default namespace as an open, ungated write space. Descriptive only, every cell cites file:line on each repo's main branch. --- sdk-feature-matrix.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index a1f64e9..a1fb8e7 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -56,6 +56,21 @@ > ⁰ TypeScript Arrow was listed 🔜 Planned from 2026-06-06 with no code, stub, or tracking issue behind it; corrected to ❌, with LAB-524 owning the implement-or-decline decision. Orjson and Arrow live behind cachekit-py's `[json]` / `[data]` extras (`pyproject.toml:73-81`). +### Namespace semantics (per-SDK divergence) + +*Audited against code 2026-09-09 (LAB-646): py `cachekit-py/src/cachekit/key_generator.py`, rs `cachekit-rs/crates/cachekit/src/client.rs`, ts `cachekit-ts/packages/cachekit/src/serialization/key-generator.ts` + `types/cache.ts` + `cache-core.ts`, server `saas/apps/cache/src/cache-key-validator.ts` + `namespace-validator.ts` (all `main`). PHP has no namespace implementation to audit. Records what each SDK actually puts on the wire, not what the protocol prescribes — no new key-format requirement is stated or implied here.* + +| Semantic | Python | Rust | TypeScript | SaaS server | +| :--- | :--- | :--- | :--- | :--- | +| Key-prefix shape (auto mode) | `ns:{namespace}:func:...` — namespace is its own delimited segment (`spec/cache-key-format.md:36`, `key_generator.py:94-95`) | `{namespace}:{key}` when `.namespace()` is set on the builder — one colon, no `ns:` token (`client.rs:246-250`) | `{namespace}:{blake2b-hex}` — same shape as Rust, no `ns:` token (`key-generator.ts:33-44`) | Only a leading `ns:` or `nsapi:` token is parsed as a namespace prefix; anything else is opaque (`cache-key-validator.ts:50,105-120`) | +| Default namespace (nothing configured) | No SDK-level default — omitting `namespace` drops the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default — `namespace: Option`, `None` → bare key (`client.rs:246-250`) | No SDK-level default — `namespace` is a required, non-optional field with nothing to fall back to (`types/cache.ts:56`) | Any key without a `ns:`/`nsapi:` prefix — every TS/Rust auto-mode key, every interop key, any bare hash — resolves to the literal namespace `default` (`cache-key-validator.ts:118-120`) | +| Empty/unset behaviour | `None` and `""` are both falsy → identical: no `ns:` segment emitted (`key_generator.py:94`) | `None` (never call `.namespace()`) → bare key; `.namespace("")` is accepted unchecked and produces a leading-colon key `:{key}` — a distinct path, not rejected (`client.rs:246-250,913-916`) | `namespace: string` is required at the type level, so "unset" isn't a legal call; an empty string still reaches `generateKey` unchecked and produces a leading-colon key `:{hash}` (`types/cache.ts:56`, `key-generator.ts:44`) | An all-empty raw key is rejected by the length check before namespace shape is inspected (`cache-key-validator.ts:81-83`); a non-empty key with a leading colon isn't `ns:`/`nsapi:`-shaped, so it falls through to the unprefixed/`default` path (`cache-key-validator.ts:118-120`) | +| Charset validation on the namespace value | None — the raw string is spliced into the key; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None — `.namespace()` accepts any `impl Into` (`client.rs:913-916`); `validate_key` only checks the per-call key argument, never the namespace (`client.rs:76-96`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments ARE validated against `^[a-z0-9][a-z0-9._-]{0,63}$` at wrap time (`validateInteropSegment`, `cache-core.ts:915`, `types/cache.ts:85-86`) | Enforced only when the key claims a prefix — a `ns:`/`nsapi:` namespace segment must be 1-64 chars of `[a-zA-Z0-9_-]` or the whole key is rejected (`cache-key-validator.ts:50,106-114`); unprefixed keys skip this check entirely | +| Server-side isolation | `ns:`-prefixed keys are checked against the API key's `allowed_namespaces` grant (`namespace-validator.ts:17-31`, called from `saas/apps/cache/src/index.ts:770`) | None — every key this SDK writes is unprefixed and lands in `default`, ungated by any namespace-specific grant | None — same as Rust, every auto-mode key lands in `default` | The same `allowed_namespaces` check applies to whatever namespace a key resolves to, `default` included (`namespace-validator.ts:17-31`) — so an API key scoped away from `default` blocks unprefixed traffic too, but by default `allowed_namespaces` is unrestricted (`IS NULL`) and every unprefixed writer shares one ungated `default` bucket | + +> [!NOTE] +> Unprefixed keys are an **open write space**: every TS or Rust SDK key, every interop-mode key, and any bare hash all collapse into the same server-side `default` namespace (`cache-key-validator.ts:56-59,118-120`, `keyClass: 'open'`). This is current behavior, not a recommendation — whether TS/Rust should adopt `ns:`-style prefixing to opt into per-namespace isolation is LAB-640's decision, not this document's. + --- ## Encryption From 2d9397e3f38ceab7c7c54ef80fba0ec9f92c009a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 11 Sep 2026 16:22:33 +1000 Subject: [PATCH 2/3] docs(matrix): fix isolation-claim wording per review (LAB-646) CodeRabbit flagged the Rust/TS "server-side isolation" cells as factually inconsistent with the Server column: both said the same allowed_namespaces check applies to whatever namespace a key resolves to, default included, so calling Rust/TS traffic "ungated" contradicted the very next cell. Reworded: Rust/TS keys resolve to default and are checked by the same ACL, they just cannot be scoped to anything narrower than default. Clarified the open-write-space note to mean the writer-class boundary (ck_sdk_/ck_api_), not an absence of authorization. --- sdk-feature-matrix.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index a1fb8e7..4bf2e31 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -66,10 +66,10 @@ | Default namespace (nothing configured) | No SDK-level default — omitting `namespace` drops the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default — `namespace: Option`, `None` → bare key (`client.rs:246-250`) | No SDK-level default — `namespace` is a required, non-optional field with nothing to fall back to (`types/cache.ts:56`) | Any key without a `ns:`/`nsapi:` prefix — every TS/Rust auto-mode key, every interop key, any bare hash — resolves to the literal namespace `default` (`cache-key-validator.ts:118-120`) | | Empty/unset behaviour | `None` and `""` are both falsy → identical: no `ns:` segment emitted (`key_generator.py:94`) | `None` (never call `.namespace()`) → bare key; `.namespace("")` is accepted unchecked and produces a leading-colon key `:{key}` — a distinct path, not rejected (`client.rs:246-250,913-916`) | `namespace: string` is required at the type level, so "unset" isn't a legal call; an empty string still reaches `generateKey` unchecked and produces a leading-colon key `:{hash}` (`types/cache.ts:56`, `key-generator.ts:44`) | An all-empty raw key is rejected by the length check before namespace shape is inspected (`cache-key-validator.ts:81-83`); a non-empty key with a leading colon isn't `ns:`/`nsapi:`-shaped, so it falls through to the unprefixed/`default` path (`cache-key-validator.ts:118-120`) | | Charset validation on the namespace value | None — the raw string is spliced into the key; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None — `.namespace()` accepts any `impl Into` (`client.rs:913-916`); `validate_key` only checks the per-call key argument, never the namespace (`client.rs:76-96`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments ARE validated against `^[a-z0-9][a-z0-9._-]{0,63}$` at wrap time (`validateInteropSegment`, `cache-core.ts:915`, `types/cache.ts:85-86`) | Enforced only when the key claims a prefix — a `ns:`/`nsapi:` namespace segment must be 1-64 chars of `[a-zA-Z0-9_-]` or the whole key is rejected (`cache-key-validator.ts:50,106-114`); unprefixed keys skip this check entirely | -| Server-side isolation | `ns:`-prefixed keys are checked against the API key's `allowed_namespaces` grant (`namespace-validator.ts:17-31`, called from `saas/apps/cache/src/index.ts:770`) | None — every key this SDK writes is unprefixed and lands in `default`, ungated by any namespace-specific grant | None — same as Rust, every auto-mode key lands in `default` | The same `allowed_namespaces` check applies to whatever namespace a key resolves to, `default` included (`namespace-validator.ts:17-31`) — so an API key scoped away from `default` blocks unprefixed traffic too, but by default `allowed_namespaces` is unrestricted (`IS NULL`) and every unprefixed writer shares one ungated `default` bucket | +| Server-side isolation | `ns:`-prefixed keys are checked against the API key's `allowed_namespaces` grant (`namespace-validator.ts:17-31`, called from `saas/apps/cache/src/index.ts:770`) | Resolves to `default` and is checked against the same `allowed_namespaces` grant as any other key (`namespace-validator.ts:17-31`) — but since this SDK never emits a namespace prefix, it can never be scoped to anything other than `default` | Same as Rust — every auto-mode key resolves to `default` and is subject to the same `allowed_namespaces` check, with no way to opt into a named namespace | The same `allowed_namespaces` check applies to whatever namespace a key resolves to, `default` included (`namespace-validator.ts:17-31`); `allowed_namespaces` is unrestricted (`IS NULL`) by default, so in practice every unprefixed writer shares one `default` bucket with no further authorization boundary between them | > [!NOTE] -> Unprefixed keys are an **open write space**: every TS or Rust SDK key, every interop-mode key, and any bare hash all collapse into the same server-side `default` namespace (`cache-key-validator.ts:56-59,118-120`, `keyClass: 'open'`). This is current behavior, not a recommendation — whether TS/Rust should adopt `ns:`-style prefixing to opt into per-namespace isolation is LAB-640's decision, not this document's. +> Unprefixed keys — every TS or Rust SDK key, every interop-mode key, any bare hash — all resolve to the single server-side `default` namespace and share its `keyClass: 'open'` write space, open to both `ck_sdk_` and `ck_api_` callers alike (`cache-key-validator.ts:56-59,118-120`). They are still subject to whatever `allowed_namespaces` grant applies to `default`; "open" describes the writer-class boundary, not an absence of authorization. What they cannot do is scope traffic to anything narrower than `default` — that segmentation exists only for `ns:`/`nsapi:`-prefixed keys. This is current behavior, not a recommendation — whether TS/Rust should adopt `ns:`-style prefixing to gain per-namespace segmentation is LAB-640's decision, not this document's. --- From 626c98beb99bde89cf3b221e9b3e30d846bcc7c2 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 11 Sep 2026 16:24:05 +1000 Subject: [PATCH 3/3] docs(matrix): correct namespace-escape claim per coderabbitai (LAB-646) coderabbitai's re-review on the prior fix caught the deeper issue: saying rust/ts traffic "can never be scoped to anything other than default" is still wrong. Neither SDK validates the namespace value's charset, and namespace is spliced directly ahead of the key with one colon, so a namespace value that itself starts ns:/nsapi: (coderabbitai's own example: .namespace("ns:tenant")) produces a key the SaaS genuinely parses as that literal named namespace. Reworded the Charset validation and Server-side isolation cells, and the trailing note, to state the real behavior: default is the outcome for ordinary namespace values, not a hard confinement. --- sdk-feature-matrix.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 4bf2e31..0b76446 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -65,11 +65,11 @@ | Key-prefix shape (auto mode) | `ns:{namespace}:func:...` — namespace is its own delimited segment (`spec/cache-key-format.md:36`, `key_generator.py:94-95`) | `{namespace}:{key}` when `.namespace()` is set on the builder — one colon, no `ns:` token (`client.rs:246-250`) | `{namespace}:{blake2b-hex}` — same shape as Rust, no `ns:` token (`key-generator.ts:33-44`) | Only a leading `ns:` or `nsapi:` token is parsed as a namespace prefix; anything else is opaque (`cache-key-validator.ts:50,105-120`) | | Default namespace (nothing configured) | No SDK-level default — omitting `namespace` drops the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default — `namespace: Option`, `None` → bare key (`client.rs:246-250`) | No SDK-level default — `namespace` is a required, non-optional field with nothing to fall back to (`types/cache.ts:56`) | Any key without a `ns:`/`nsapi:` prefix — every TS/Rust auto-mode key, every interop key, any bare hash — resolves to the literal namespace `default` (`cache-key-validator.ts:118-120`) | | Empty/unset behaviour | `None` and `""` are both falsy → identical: no `ns:` segment emitted (`key_generator.py:94`) | `None` (never call `.namespace()`) → bare key; `.namespace("")` is accepted unchecked and produces a leading-colon key `:{key}` — a distinct path, not rejected (`client.rs:246-250,913-916`) | `namespace: string` is required at the type level, so "unset" isn't a legal call; an empty string still reaches `generateKey` unchecked and produces a leading-colon key `:{hash}` (`types/cache.ts:56`, `key-generator.ts:44`) | An all-empty raw key is rejected by the length check before namespace shape is inspected (`cache-key-validator.ts:81-83`); a non-empty key with a leading colon isn't `ns:`/`nsapi:`-shaped, so it falls through to the unprefixed/`default` path (`cache-key-validator.ts:118-120`) | -| Charset validation on the namespace value | None — the raw string is spliced into the key; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None — `.namespace()` accepts any `impl Into` (`client.rs:913-916`); `validate_key` only checks the per-call key argument, never the namespace (`client.rs:76-96`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments ARE validated against `^[a-z0-9][a-z0-9._-]{0,63}$` at wrap time (`validateInteropSegment`, `cache-core.ts:915`, `types/cache.ts:85-86`) | Enforced only when the key claims a prefix — a `ns:`/`nsapi:` namespace segment must be 1-64 chars of `[a-zA-Z0-9_-]` or the whole key is rejected (`cache-key-validator.ts:50,106-114`); unprefixed keys skip this check entirely | -| Server-side isolation | `ns:`-prefixed keys are checked against the API key's `allowed_namespaces` grant (`namespace-validator.ts:17-31`, called from `saas/apps/cache/src/index.ts:770`) | Resolves to `default` and is checked against the same `allowed_namespaces` grant as any other key (`namespace-validator.ts:17-31`) — but since this SDK never emits a namespace prefix, it can never be scoped to anything other than `default` | Same as Rust — every auto-mode key resolves to `default` and is subject to the same `allowed_namespaces` check, with no way to opt into a named namespace | The same `allowed_namespaces` check applies to whatever namespace a key resolves to, `default` included (`namespace-validator.ts:17-31`); `allowed_namespaces` is unrestricted (`IS NULL`) by default, so in practice every unprefixed writer shares one `default` bucket with no further authorization boundary between them | +| Charset validation on the namespace value | None — the raw string is spliced into the key; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None — `.namespace()` accepts any `impl Into` (`client.rs:913-916`); `validate_key` only checks the per-call key argument, never the namespace (`client.rs:76-96`). Because the namespace is spliced directly ahead of the key with one colon (`client.rs:246-250`), a namespace value that itself begins `ns:` or `nsapi:` (e.g. `.namespace("ns:tenant")`) produces a key the SaaS parses as that literal named namespace — see Server-side isolation | None in auto mode (`key-generator.ts:44`) — same splice, same risk as Rust (`${namespace}:${hash}`); interop mode's namespace/operation segments ARE validated against `^[a-z0-9][a-z0-9._-]{0,63}$` at wrap time (`validateInteropSegment`, `cache-core.ts:915`, `types/cache.ts:85-86`), which closes this gap for interop keys only | Enforced only when the key claims a prefix — a `ns:`/`nsapi:` namespace segment must be 1-64 chars of `[a-zA-Z0-9_-]` or the whole key is rejected (`cache-key-validator.ts:50,106-114`); unprefixed keys skip this check entirely. The parser has no notion of which SDK produced the string — it re-splits on `:` from scratch | +| Server-side isolation | `ns:`-prefixed keys are checked against the API key's `allowed_namespaces` grant (`namespace-validator.ts:17-31`, called from `saas/apps/cache/src/index.ts:770`) | A namespace value with no embedded `ns:`/`nsapi:` colon resolves to `default`, checked against the same `allowed_namespaces` grant as any other key; a namespace value that itself starts with `ns:`/`nsapi:` (unvalidated client-side — see Charset validation) instead lands in that literal named namespace, since the SaaS re-parses the finished key with no knowledge of SDK-side intent | Same as Rust — resolves to `default` under the same `allowed_namespaces` check, unless the unvalidated namespace value itself begins `ns:`/`nsapi:`, in which case it lands in that literal named namespace instead | The same `allowed_namespaces` check applies to whatever namespace a key resolves to, `default` included (`namespace-validator.ts:17-31`); `allowed_namespaces` is unrestricted (`IS NULL`) by default, so in practice every writer that stays clear of a `ns:`/`nsapi:`-shaped namespace value shares one `default` bucket with no further authorization boundary between them | > [!NOTE] -> Unprefixed keys — every TS or Rust SDK key, every interop-mode key, any bare hash — all resolve to the single server-side `default` namespace and share its `keyClass: 'open'` write space, open to both `ck_sdk_` and `ck_api_` callers alike (`cache-key-validator.ts:56-59,118-120`). They are still subject to whatever `allowed_namespaces` grant applies to `default`; "open" describes the writer-class boundary, not an absence of authorization. What they cannot do is scope traffic to anything narrower than `default` — that segmentation exists only for `ns:`/`nsapi:`-prefixed keys. This is current behavior, not a recommendation — whether TS/Rust should adopt `ns:`-style prefixing to gain per-namespace segmentation is LAB-640's decision, not this document's. +> Unprefixed keys — every TS or Rust SDK key whose namespace value doesn't itself start `ns:`/`nsapi:`, every interop-mode key, any bare hash — resolve to the single server-side `default` namespace and share its `keyClass: 'open'` write space, open to both `ck_sdk_` and `ck_api_` callers alike (`cache-key-validator.ts:56-59,118-120`). They are still subject to whatever `allowed_namespaces` grant applies to `default`; "open" describes the writer-class boundary, not an absence of authorization. Because neither Rust nor TypeScript validates the namespace value's charset, a namespace string that itself begins `ns:`/`nsapi:` is not rejected client-side and is instead parsed by the SaaS as that literal named namespace — an unintended escape from `default`, not a supported way to opt in. This is current behavior, not a recommendation — whether TS/Rust should validate/reject such values, or adopt `ns:`-style prefixing outright, is LAB-640's decision, not this document's. ---