From 70e8ac83eea876dc8f75e63069431145f0cef803 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 9 Sep 2026 03:43:27 +1000 Subject: [PATCH 1/8] 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/8] 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/8] 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. --- From 984160feb883b48921a58fec161656e2e9e2d28a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 20 Sep 2026 00:47:33 +1000 Subject: [PATCH 4/8] docs(matrix): cite only public specs, drop internal references (LAB-646) The namespace-semantics section cited private-repo file paths and an internal ticket reference that don't belong in a public spec repo. Reworked to: - cite server-side behavior against the public spec (cache-key-format.md, saas-api.md, interop-mode.md) instead of private server source - drop the internal ticket reference and process language - drop the private server's default-namespace naming and internal write-space vocabulary; state only what's publicly specified - fold empty/unset behavior into the default-namespace row and drop the worked reproduction case, tightening the section by roughly a third with no loss of the SDK-side facts (all cited against public SDK repos) --- sdk-feature-matrix.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 0b76446..27e7796 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -58,18 +58,16 @@ ### 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.* +*Re-verified 2026-09-20 (LAB-646) against 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` โ€” all public SDK repos, `main`. Server-side behavior is cited via the public spec only; PHP has no namespace implementation to audit. Descriptive only, no new key-format requirement.* -| 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`). 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 | +| Semantic | Python | Rust | TypeScript | +| :--- | :--- | :--- | :--- | +| Key-prefix shape (auto mode) | `ns:{namespace}:func:...` โ€” a dedicated `ns:` token (`spec/cache-key-format.md:36`, `key_generator.py:94-95`) | `{namespace}:{key}` when `.namespace()` is set on the builder, else the bare key โ€” one colon, no `ns:` token (`client.rs:246-250`) | `{namespace}:{blake2b-hex}` โ€” same one-colon shape as Rust, no `ns:` token (`key-generator.ts:33-44`) | +| Default / unset namespace | Falsy (`None`/`""`) omits the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default โ€” `namespace: Option`, `None` โ†’ bare key (`client.rs:246-250`) | Required, non-optional field with nothing to fall back to (`types/cache.ts:56`); an empty string still reaches `generateKey` unchecked (`key-generator.ts:44`) | +| Charset validation on the namespace value | None โ€” spliced in as-is; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None โ€” `.namespace()` accepts any `impl Into` (`client.rs:913-916`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:915`) | > [!NOTE] -> 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. +> The protocol recognizes a leading `ns:`/`nsapi:` token as a namespace prefix; a key without one is treated as unscoped (`spec/cache-key-format.md:36,41`). None of the three SDKs validate the namespace value's charset before splicing it into the key (see row above), so a caller-supplied value matching that recognized shape is not rejected client-side by any of them. Authorization on the resolved namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). --- From 4fbd4d2397b49f321b2a0f912cfc8d7fa0e439ad Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 20 Sep 2026 01:02:25 +1000 Subject: [PATCH 5/8] docs(matrix): fix two rotted SDK line citations (LAB-646) Both citations were correct when first audited and moved on the cited repos' main the same day the previous head was pushed: - cachekit-rs client.rs: `.namespace()` shifted from 913-916 to 918-922 (cachekit-rs#74, 2026-09-19 12:58Z). 913-916 is now `default_ttl`. - cachekit-ts cache-core.ts: the `validateInteropSegment` call sites are 922-923; line 915 is inside a ConfigurationError message. Re-verified against each repo's main on 2026-09-20. --- sdk-feature-matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 27e7796..2875680 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -64,7 +64,7 @@ | :--- | :--- | :--- | :--- | | Key-prefix shape (auto mode) | `ns:{namespace}:func:...` โ€” a dedicated `ns:` token (`spec/cache-key-format.md:36`, `key_generator.py:94-95`) | `{namespace}:{key}` when `.namespace()` is set on the builder, else the bare key โ€” one colon, no `ns:` token (`client.rs:246-250`) | `{namespace}:{blake2b-hex}` โ€” same one-colon shape as Rust, no `ns:` token (`key-generator.ts:33-44`) | | Default / unset namespace | Falsy (`None`/`""`) omits the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default โ€” `namespace: Option`, `None` โ†’ bare key (`client.rs:246-250`) | Required, non-optional field with nothing to fall back to (`types/cache.ts:56`); an empty string still reaches `generateKey` unchecked (`key-generator.ts:44`) | -| Charset validation on the namespace value | None โ€” spliced in as-is; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None โ€” `.namespace()` accepts any `impl Into` (`client.rs:913-916`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:915`) | +| Charset validation on the namespace value | None โ€” spliced in as-is; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None โ€” `.namespace()` accepts any `impl Into` (`client.rs:918-922`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:922-923`) | > [!NOTE] > The protocol recognizes a leading `ns:`/`nsapi:` token as a namespace prefix; a key without one is treated as unscoped (`spec/cache-key-format.md:36,41`). None of the three SDKs validate the namespace value's charset before splicing it into the key (see row above), so a caller-supplied value matching that recognized shape is not rejected client-side by any of them. Authorization on the resolved namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). From f5b615291c2737d6385402597f100615e84f0592 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 20 Sep 2026 02:00:24 +1000 Subject: [PATCH 6/8] docs(matrix): use UTC verification date, cite only the specced ns: token (LAB-646) - Line 61: 2026-09-20 was the author's local (+10:00) calendar date at commit time; the review environment and every commit timestamp are UTC, where the re-verification happened on 2026-09-19. All eleven file:line citations were re-checked against each SDK repo's main at 2026-09-19T15:57Z (cachekit-py 0aa78ca8, cachekit-rs 5b3ec140, cachekit-ts 87dee26f) and are current. - Line 70: the NOTE cited spec/cache-key-format.md:36,41 for an `nsapi:` prefix token that appears nowhere in the public spec; those lines define only `ns:`. Drop the undocumented token so the citation says exactly what its source says. Co-Authored-By: Claude Fable 5.1 --- 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 2875680..aab78c9 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -58,7 +58,7 @@ ### Namespace semantics (per-SDK divergence) -*Re-verified 2026-09-20 (LAB-646) against 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` โ€” all public SDK repos, `main`. Server-side behavior is cited via the public spec only; PHP has no namespace implementation to audit. Descriptive only, no new key-format requirement.* +*Re-verified 2026-09-19 (LAB-646) against 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` โ€” all public SDK repos, `main`. Server-side behavior is cited via the public spec only; PHP has no namespace implementation to audit. Descriptive only, no new key-format requirement.* | Semantic | Python | Rust | TypeScript | | :--- | :--- | :--- | :--- | @@ -67,7 +67,7 @@ | Charset validation on the namespace value | None โ€” spliced in as-is; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None โ€” `.namespace()` accepts any `impl Into` (`client.rs:918-922`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:922-923`) | > [!NOTE] -> The protocol recognizes a leading `ns:`/`nsapi:` token as a namespace prefix; a key without one is treated as unscoped (`spec/cache-key-format.md:36,41`). None of the three SDKs validate the namespace value's charset before splicing it into the key (see row above), so a caller-supplied value matching that recognized shape is not rejected client-side by any of them. Authorization on the resolved namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). +> The protocol recognizes a leading `ns:` token as a namespace prefix; a key without one is treated as unscoped (`spec/cache-key-format.md:36,41`). None of the three SDKs validate the namespace value's charset before splicing it into the key (see row above), so a caller-supplied value matching that recognized shape is not rejected client-side by any of them. Authorization on the resolved namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). --- From 1d4fab273da766751add92286e9767d79cc8d675 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 20 Sep 2026 02:12:58 +1000 Subject: [PATCH 7/8] docs(matrix): Rust has no auto-mode key; correct its namespace validation cells (LAB-646) Review finding at f5b6152: the "Key-prefix shape (auto mode)" Rust cell described `.namespace()` prefixing caller-supplied `get`/`set` keys, which is not an auto-mode key. Note 14 already says cachekit-rs implements no auto-mode format (the `#[cachekit]` macro mints interop/v1 keys only). The Rust cell is now N/A and the `{namespace}:{key}` behaviour moves to a new "Key-prefix shape (caller-supplied key)" row, alongside Python (N/A, decorator-only API) and TypeScript (key passed to the backend verbatim; the `namespace` set-option is an L1 invalidation-grouping hint, not a prefix). Verifying that row surfaced a false cell: "Charset validation: None" for Rust. `CacheKitBuilder::build()` rejects empty, >255-byte, or non-printable-ASCII namespaces (client.rs:1088-1101) and has since at least 2026-08-08; the original audit checked the setter, not `build()`. The cell and the NOTE below it now state the check precisely. `:` is printable ASCII, so the conclusion that an `ns:`-shaped value is not rejected client-side still holds. The Rust empty-string case joins the "Default / unset" row for parity with the Python and TypeScript cells. All citations verified against each SDK repo's main on 2026-09-19 UTC (cachekit-py 0aa78ca8, cachekit-rs 5b3ec140, cachekit-ts 87dee26f). --- sdk-feature-matrix.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index aab78c9..4315247 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -62,12 +62,13 @@ | Semantic | Python | Rust | TypeScript | | :--- | :--- | :--- | :--- | -| Key-prefix shape (auto mode) | `ns:{namespace}:func:...` โ€” a dedicated `ns:` token (`spec/cache-key-format.md:36`, `key_generator.py:94-95`) | `{namespace}:{key}` when `.namespace()` is set on the builder, else the bare key โ€” one colon, no `ns:` token (`client.rs:246-250`) | `{namespace}:{blake2b-hex}` โ€” same one-colon shape as Rust, no `ns:` token (`key-generator.ts:33-44`) | -| Default / unset namespace | Falsy (`None`/`""`) omits the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default โ€” `namespace: Option`, `None` โ†’ bare key (`client.rs:246-250`) | Required, non-optional field with nothing to fall back to (`types/cache.ts:56`); an empty string still reaches `generateKey` unchecked (`key-generator.ts:44`) | -| Charset validation on the namespace value | None โ€” spliced in as-is; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | None โ€” `.namespace()` accepts any `impl Into` (`client.rs:918-922`) | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:922-923`) | +| Key-prefix shape (auto mode) | `ns:{namespace}:func:...` โ€” a dedicated `ns:` token (`spec/cache-key-format.md:36`, `key_generator.py:94-95`) | N/A โ€” no auto-mode key format; `get`/`set` take caller-supplied keys and the `#[cachekit]` macro mints interop/v1 keys only (see [Compliance Status](#compliance-status) note ยนโด) | `{namespace}:{blake2b-hex}` โ€” one colon, no `ns:` token (`key-generator.ts:33-44`) | +| Key-prefix shape (caller-supplied key) | N/A โ€” decorator-only public API, no key-taking `get`/`set` | `{namespace}:{key}` when `.namespace()` is set on the builder (or `CACHEKIT_NAMESPACE` via `from_env`, `client.rs:224-226`), else the bare key โ€” one colon, no `ns:` token (`client.rs:246-250`) | `get`/`set`/`delete` hand the key to the backend verbatim โ€” no prefix applied (`cache-core.ts:729-731`); the `namespace` set-option only groups the entry for L1 invalidation (`types/cache.ts:47-48`, `cache-core.ts:768`) | +| Default / unset namespace | Falsy (`None`/`""`) omits the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default โ€” `namespace: Option`, `None` โ†’ bare key (`client.rs:246-250`); `Some("")` is rejected at `build()` (`client.rs:1090-1092`) | Required, non-optional field with nothing to fall back to (`types/cache.ts:56`); an empty string still reaches `generateKey` unchecked (`key-generator.ts:44`) | +| Charset validation on the namespace value | None โ€” spliced in as-is; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | Setter accepts any `impl Into` (`client.rs:918-922`); `build()` rejects empty, >255-byte, or non-printable-ASCII values (`client.rs:1088-1101`) โ€” `:` is printable ASCII, so an `ns:`-shaped value passes | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:922-923`) | > [!NOTE] -> The protocol recognizes a leading `ns:` token as a namespace prefix; a key without one is treated as unscoped (`spec/cache-key-format.md:36,41`). None of the three SDKs validate the namespace value's charset before splicing it into the key (see row above), so a caller-supplied value matching that recognized shape is not rejected client-side by any of them. Authorization on the resolved namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). +> The protocol recognizes a leading `ns:` token as a namespace prefix; a key without one is treated as unscoped (`spec/cache-key-format.md:36,41`). None of the three SDKs reject a `:` inside the namespace value (Rust's `build()` check is printable-ASCII only โ€” see row above), so a caller-supplied value matching that recognized shape is not rejected client-side by any of them. Authorization on the resolved namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). --- From 8045fcc4c05ab01e024d75fe2e5470cf3d404d37 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 20 Sep 2026 05:15:01 +1000 Subject: [PATCH 8/8] docs(matrix): drop fabricated env var, fix drifted citations, cut NOTE (LAB-646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust caller-supplied cell claimed a CACHEKIT_NAMESPACE env var read via from_env() โ€” no such variable exists anywhere in the crate, and from_env() never touches the namespace field. Removed the claim. - Re-synced cachekit-ts to its current main before citing it: the previous cache-core.ts:729-731 citation was the set() wrapper, not the verbatim backend handoff. Corrected to the three actual backend call sites. - Added the missing citation for the "spliced in as-is" half of the Python charset cell (the existing citation only covered the func: segment). - Cut the NOTE to the one sentence that is fully spec-cited and doesn't restate the table above it. --- 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 4315247..fad50c7 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -63,12 +63,12 @@ | Semantic | Python | Rust | TypeScript | | :--- | :--- | :--- | :--- | | Key-prefix shape (auto mode) | `ns:{namespace}:func:...` โ€” a dedicated `ns:` token (`spec/cache-key-format.md:36`, `key_generator.py:94-95`) | N/A โ€” no auto-mode key format; `get`/`set` take caller-supplied keys and the `#[cachekit]` macro mints interop/v1 keys only (see [Compliance Status](#compliance-status) note ยนโด) | `{namespace}:{blake2b-hex}` โ€” one colon, no `ns:` token (`key-generator.ts:33-44`) | -| Key-prefix shape (caller-supplied key) | N/A โ€” decorator-only public API, no key-taking `get`/`set` | `{namespace}:{key}` when `.namespace()` is set on the builder (or `CACHEKIT_NAMESPACE` via `from_env`, `client.rs:224-226`), else the bare key โ€” one colon, no `ns:` token (`client.rs:246-250`) | `get`/`set`/`delete` hand the key to the backend verbatim โ€” no prefix applied (`cache-core.ts:729-731`); the `namespace` set-option only groups the entry for L1 invalidation (`types/cache.ts:47-48`, `cache-core.ts:768`) | +| Key-prefix shape (caller-supplied key) | N/A โ€” decorator-only public API, no key-taking `get`/`set` | `{namespace}:{key}` when `.namespace()` is set on the builder, else the bare key โ€” one colon, no `ns:` token (`client.rs:246-250`) | `get`/`set`/`delete` hand the key to the backend verbatim โ€” no prefix applied (`cache-core.ts:683,829,856`); the `namespace` set-option only groups the entry for L1 invalidation (`types/cache.ts:47-48`, `cache-core.ts:768`) | | Default / unset namespace | Falsy (`None`/`""`) omits the `ns:` segment entirely (`key_generator.py:94-95`) | No SDK-level default โ€” `namespace: Option`, `None` โ†’ bare key (`client.rs:246-250`); `Some("")` is rejected at `build()` (`client.rs:1090-1092`) | Required, non-optional field with nothing to fall back to (`types/cache.ts:56`); an empty string still reaches `generateKey` unchecked (`key-generator.ts:44`) | -| Charset validation on the namespace value | None โ€” spliced in as-is; only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | Setter accepts any `impl Into` (`client.rs:918-922`); `build()` rejects empty, >255-byte, or non-printable-ASCII values (`client.rs:1088-1101`) โ€” `:` is printable ASCII, so an `ns:`-shaped value passes | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:922-923`) | +| Charset validation on the namespace value | None โ€” spliced in as-is (`key_generator.py:94-95`); only the unrelated `func:` segment is sanitized (`key_generator.py:364-381`) | Setter accepts any `impl Into` (`client.rs:918-922`); `build()` rejects empty, >255-byte, or non-printable-ASCII values (`client.rs:1088-1101`) โ€” `:` is printable ASCII, so an `ns:`-shaped value passes | None in auto mode (`key-generator.ts:44`); interop mode's namespace/operation segments are validated against a fixed pattern at wrap time (`validateInteropSegment`, `cache-core.ts:922-923`) | > [!NOTE] -> The protocol recognizes a leading `ns:` token as a namespace prefix; a key without one is treated as unscoped (`spec/cache-key-format.md:36,41`). None of the three SDKs reject a `:` inside the namespace value (Rust's `build()` check is printable-ASCII only โ€” see row above), so a caller-supplied value matching that recognized shape is not rejected client-side by any of them. Authorization on the resolved namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). +> Authorization on the namespace is a per-API-key grant โ€” a request the key isn't permitted for is refused with `403` (`spec/saas-api.md:417`) โ€” and is separate from tenant isolation, which comes from authentication rather than key parsing (`spec/interop-mode.md:375-376`). ---