Skip to content

test(metadata-protocol,rest): the cached /meta ETag folds the SUPPLIED organization — measured, pinned, not a fault - #17292

Merged
os-sam merged 5 commits into
mainfrom
claude/issue-16525-getmetaitemcached-etag-scope
Sep 10, 2026
Merged

test(metadata-protocol,rest): the cached /meta ETag folds the SUPPLIED organization — measured, pinned, not a fault#17292
os-sam merged 5 commits into
mainfrom
claude/issue-16525-getmetaitemcached-etag-scope

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #16525

This is an investigation card, and the answer is a third one — neither of the two branches the filing seat named. The ETag folds the SUPPLIED organizationId, so branch 2 is syntactically correct. But the divergence it feared is unreachable at the only production door, and where it is reachable it is not a cache-correctness fault. No behaviour changes here. What lands is the measurement, made durable.


(a) Which value is folded — measured

packages/metadata-protocol/src/protocol.ts:11956-11965 (getMetaItemCached, declared at :11854):

const content = JSON.stringify(item);
const scope = [
    request.organizationId ? `org:${request.organizationId}` : undefined,   // the SUPPLIED value
    request.locale || undefined,
].filter((part): part is string => part !== undefined);
const hash = simpleHash(
    scope.length > 0
        ? [...scope, content].join(SEP)   // SEP is a NUL escape in the source
        : content,
);

The SUPPLIED request member. The registry reduction — organizationIdForMetaRead (packages/metadata-core/src/meta-write-org-scope.ts:184) — runs one layer BELOW this line, inside getMetaItem at packages/metadata-protocol/src/protocol.ts:7818, and its result never travels back up to the hash.

(b) Is the divergence reachable — measured, and the answer has two halves

Half one: by a direct call on the published verb, YES. object is declared allowOrgOverride: false (packages/spec/src/kernel/metadata-plugin.zod.ts:724). Supply an organization for it and the gate answers undefined, the null-coalescing fallback at protocol.ts:7922-7923 falls to the env-wide record, and the validator still names the organization. Reproduced end to end in packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts §1 — byte-identical bodies, two validators.

Half two: through the REST cached door, NO — the door pre-gates. packages/rest/src/rest-server.ts:6607-6611 computes

const readOrganizationId = organizationIdForMetaRead(
    canonicalMetaUrlType(req.params.type), readCtx?.tenantId,
);

above the cached/uncached fork and spreads THAT into the cached request at :6656, which is handed to getMetaItemCached at :6658. The protocol's own fold (canonicalizeMetaRequestType at protocol.ts:266 reaching canonicalMetaType at :233, which is canonicalMetaUrlType) is the same fold the door used, and organizationIdForMetaRead is idempotent. ⇒ At that door supplied equals effective, and rest-server.ts:6658 is the only non-test invocation of this verb in the repository (grep over every .ts / .tsx / .mjs outside dist/ and outside *.test.ts).

⭐ Why the reachable half is a COST and not a fault

content — the bytes actually being sent — is inside the same hash (protocol.ts:11956, :11961-11965). A 304 is therefore answered only on an exact match over those bytes, so a caller is only ever pinned to the representation it itself received. The harm the card names ("a validator claiming to describe an org-scoped representation that was never served") cannot reach anybody. What is left is validator fragmentation: N organizations reading one env-wide document hold N validators for identical content. Waste, not error.

Not repaired, deliberately. Folding the effective value would change every published ETag that carries an organization — one forced miss per caller per deploy — and buys nothing at the door above, where the two values already agree. That is a decision, not a regression fix, and it is not this card's to take.


What lands

Two pins and one comment correction. No production code path changes.

  1. packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts (new, 9 cases) — §1 the supplied-value characterization, §2 the fallback's reachability with the control that the harness can resolve an org row, §3 the content-in-hash invariant that makes §1 harmless.
  2. packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts (new, 7 cases) — the door's pre-gate, observed through the ETag response header. Nothing pinned this before. The sibling rest-server-meta-read-org-scope.test.ts says so in its own header: "it pins that the arm still FOLDS, never that the fold happens at the door."
  3. packages/metadata-protocol/src/protocol.ts — comment only. The fold's rationale argued from "any future path that resolves an org row but falls back to the env-wide body". That path is present today (§2 above). Corrected to present tense, plus the two facts a reader of that block could not previously get from it: which value is folded, and that content in the hash is what makes it safe.
  4. scripts/engine-double-contract.pinned.json — written by node scripts/check-engine-double-contract.mjs --write, as that gate's own failure text instructed. It reports 0 seams added, grown or lost.

Ablations — both fired, both restored

⚠️ Every run below is from a committed tree; each restore is proved by git diff HEAD empty and by blob-hash equality against the HEAD blob, not by an exit code.

A — remove the door's pre-gate (rest-server.ts:6607-6611 replaced by a raw readCtx?.tenantId):

suite before after ablation
rest-server-meta-cached-etag-door-scope.test.ts (new) 7 passed 2 failed / 5 passed
rest-server-meta-read-org-scope.test.ts (sibling) 41 passed 41 passed — green

The two that reddened are exactly §1's non-overridable case and §3's counterfactual:

AssertionError: the cached door stopped reducing the organization
  expected '"79f4f692"' to be '"6d7cef81"'
AssertionError: the door's pre-gate no longer changes the validator
  expected '"79f4f692"' not to be '"79f4f692"'

⭐ The control inside §1 — an overridable type, where the two validators must DIFFER — stayed green, so the ablation did not simply break the observation channel. And the sibling staying green at 41/41 is the measured confirmation of the gap its own header records: the callee re-folds for the BODY, not for the validator.

B — remove content from the hash (protocol.ts:11961-11965 reduced to the scope alone):

suite before after ablation
get-meta-item-cached-etag-scope.test.ts (new) 9 passed 1 failed / 8 passed
x a changed document never answers 304, though the scope is unchanged
AssertionError: a stale validator was honoured across a body change
  expected true to be false

Exactly one assertion carries that invariant, and §1/§2 stay green under it — which is the point: a hash "optimization" would leave every other assertion green while destroying the argument that makes the current shape safe.

Verification

Measured at dced2447e5, exit codes captured by redirect-then-$?, never across a pipe.

check result
pnpm --filter @objectstack/metadata-protocol exec vitest run 0 — 173 passed / 2 skipped files, 2482 passed / 10 skipped
pnpm --filter @objectstack/rest exec vitest run --project local 0 — 187 files, 3118 tests
pnpm --workspace-concurrency=2 --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck 0 — includes check:test-typecheck on rest, 0 files / 0 errors
dependency closure builds for both packages 0
54 derived gate families (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack) 52 exit 0; check:engine-double-contract exit 1, then 0 after its own --write remedy

⊘ NOT MEASURED, both requiring a whole-repository build, which is CI's run and not this card's:

  • pnpm check:dual-build-cjs-loadsexit 3, PREREQUISITE NOT MET: 44 packages have no dist/. ⛔ Not a pass.
  • pnpm check:type-check-debtexit 3, same class. ⛔ Not a pass and not a finding.

The gate list was derived on a fresh tree after merging origin/main (the first derivation warned STALE TREE); re-deriving added exactly one family, pnpm check:tier-file-adoption, which was run and exits 0.

Region fence (PR #17252, protocol.ts lines 4820-5100): clear. This branch's only hunks in that file are at @@ -11919 and @@ -11922 — about 6,800 lines away.

Grade: skip-changeset, measured

files[] for both packages is ["dist","README.md","CHANGELOG.md"]. After building both:

probe hits
new comment text inside metadata-protocol files[] 0
positive control getMetaItemCached in metadata-protocol/dist 4
either new test file name in either dist 0
positive control readOrganizationId in rest/dist 2

⇒ Nothing published moves; the controls fire. scripts/engine-double-contract.pinned.json is a repo-root script asset and ships in no package.

Acceptance notes

The describe prose is already carded, and it is not a new finding. GetMetaItemCachedRequestSchema.organizationId's describe (packages/spec/src/api/protocol.zod.ts:1816-1822) opens "Selects the org partition in the ADR-0005 overlay read order… exactly as on the uncached read", which does not say that a supplied organization is gated away entirely for a type declaring allowOrgOverride: false. ⛔ Not edited — packages/spec/ belongs to the seat that filed this card.

⭐ And ⛔ not filed either, because it is already row three of an open decision-box card. I read packages/spec/src/api/protocol.zod.ts at the anchor sha c383352cb752245899b6ca7e2dc7d233405113ee, and line :1815 there is the first line of exactly this describe. Issue #16524 names :271 / :441 / :1815 and is open, awaiting the maintainer. So the triage note on this card that "本卡第 1 条改的是 GetMetaItemCachedRequestSchema 自己的 describe,⛔ 不是那三处" is mistaken on that one point: :1815 is that describe.

One correction worth carrying into that decision: the second half of this describe — "folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation" — is measurably TRUE, and is now pinned (get-meta-item-cached-etag-scope.test.ts §3, third case). Only its first sentence is imprecise. Issue #16524 remains open and untouched by this branch.

Noted, not filed (successor named, per the discipline that an unfiled observation must say who will meet it):

  • The scope-plus-content hash shape means an org-carrying caller's validator can never be shared with an org-less one even when the bytes are identical. Successor: whoever takes the fragmentation half of this card, if the maintainer ever asks for it. ⛔ Deliberately not filed — there is no measured request volume to justify it, and no defect.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU


Generated by Claude Code

… folds

`getMetaItemCached` folds `request.organizationId` — the SUPPLIED member —
into the ETag scope prefix, not the effective scope
`organizationIdForMetaRead` returns. Measured and pinned end to end:

  §1 a type declared `allowOrgOverride: false` gates the supplied
     organization away, serves the env-wide record, and still issues an
     organization-named validator — byte-identical bodies, two validators.
  §2 the "resolve an org row, fall back to env-wide" path the fold was
     written against is present today, reachable on an overridable type
     whose organization has no row, with the control that the harness can
     resolve an org row when one exists.
  §3 the served bytes are inside the same hash, so a validator can never
     pin a caller to a representation it did not receive: unchanged
     document 304s (the control that must fire), a changed document never
     does, and one organization's validator is never honoured for another.

§3 is the half that makes §1 a cost rather than a fault, and the half a
hash change would break silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
The cached door computes `organizationIdForMetaRead(canonicalMetaUrlType(
req.params.type), readCtx?.tenantId)` above the cached/uncached fork and
spreads THAT into the `getMetaItemCached` request. Because the protocol
folds the member as handed in when it computes the ETag, this reduction is
what keeps the validator's declared scope equal to the scope the body was
resolved under.

Nothing pinned it. The sibling `rest-server-meta-read-org-scope.test.ts`
records the gap in its own words -- "it pins that the arm still FOLDS,
never that the fold happens at the door" -- and its measured ablation
confirms it: swapping the door's predicate for a raw tenant leaves that
file green, because the callee re-folds. The callee re-folds for the BODY,
not for the validator.

Observed through the ETag response header across two reads of one document
differing only in the session's active organization: identical on a
non-overridable type, and -- the control that must fire -- different on an
overridable one. Plus the counterfactual, asking the protocol directly with
the raw tenant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…t as future

The fold's rationale argued from "any FUTURE path that resolves an org row
but falls back to the env-wide body". That path is present: `getMetaItem`
resolves `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)`,
so an organization with no row of its own is served the env-wide document
under an org-named validator. Reading it as future is how the next author
concludes the risk has not arrived.

Also records the two facts a reader of this block needs and could not get
from it: `request.organizationId` is the SUPPLIED member (the registry
reduction happens below, inside `getMetaItem`), and the reason neither that
nor the fallback is a correctness fault is that `content` is inside the
same hash -- so a 304 can only ever pin a caller to bytes it received, and
the residue is validator fragmentation rather than error.

Comment only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
`check:engine-double-contract` named it: the cached-ETag door pin declares a
findOne double the pinned ledger does not record, so the ledger would not
protect that file. Written by the gate's own `--write`, which reports 0
seams added, grown or lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/services-checklist.mdx (via getMetaItemCached (symbol, a method of class ObjectStackProtocolImplementation))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ab450f49a154859630dee81aeb1ba86d9665100fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a3d03423b69b6752f1001236084f31bf7fbf0b8 — the merge of head dced2447e511834beee2f5524d8d8ba7cd487908 into base ab450f49a154859630dee81aeb1ba86d9665100f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6a3d03423b69b6752f1001236084f31bf7fbf0b8 && git checkout 6a3d03423b69b6752f1001236084f31bf7fbf0b8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab450f49a154859630dee81aeb1ba86d9665100f dced2447e511834beee2f5524d8d8ba7cd487908 && git checkout -B drift-repro ab450f49a154859630dee81aeb1ba86d9665100f && git merge --no-ff dced2447e511834beee2f5524d8d8ba7cd487908

node scripts/docs-audit/affected-docs.mjs --json ab450f49a154859630dee81aeb1ba86d9665100f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ab450f49a154859630dee81aeb1ba86d9665100f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants