diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index a1f64e9..0b76446 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`). 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 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. + --- ## Encryption