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

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-loads — exit 3, PREREQUISITE NOT MET: 44 packages have no dist/. ⛔ Not a pass.
  • pnpm check:type-check-debt — exit 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 ab450f49a154859630dee81aeb1ba86d9665100f → packageMentionDocs.

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.

Merged via the queue into main with commit 386de16 Sep 10, 2026
39 checks passed
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ied org is always consulted (objectstack-ai#16524) (objectstack-ai#20177)

Fixes objectstack-ai#16524

Clause-②: no

## What this changes

Three published `.describe()` strings in
`packages/spec/src/api/protocol.zod.ts`, the `organizationId` member of:

| schema | was | now |
|:--|:--|:--|
| `GetMetaItemRequestSchema` | "Selects the org partition in the
ADR-0005 overlay read order … Absent = environment-wide read: only
env-level overlays apply and no org partition is consulted." | "When an
org partition applies, this selects it in the ADR-0005 overlay read
order … Supplying a value does not by itself guarantee an org partition
is consulted; where none applies, and whenever it is absent, the read is
environment-wide and only env-level overlays apply." |
| `GetMetaItemLayeredRequestSchema` | "Selects the org partition …
Absent = environment-wide read: `overlay` reports the env-level row
only." | "When an org partition applies, this selects it … Supplying a
value does not by itself guarantee an org partition is consulted; where
none applies, and whenever it is absent, the read is environment-wide:
`overlay` reports the env-level row only." |
| `GetMetaItemCachedRequestSchema` | "Selects the org partition …
exactly as on the uncached read. Also folded into the ETag, … Absent =
environment-wide read." | "When an org partition applies, this selects
it … exactly as on the uncached read. Also folded into the ETag, …
Supplying a value does not by itself guarantee an org partition is
consulted; where none applies, and whenever it is absent, the read is
environment-wide." |

Plus the regenerated `content/docs/references/api/protocol.mdx` (three
table rows) and an `@objectstack/spec` **patch** changeset.

Prose only: no key, export, accept set or runtime behaviour changes. A
supplied `organizationId` is still accepted on all three requests, and
the runtime still drops it where no org partition applies.

## Rulings carried

- Ruling B (comment 5567936958), maintainer, verbatim: 「协议不对不是应该修改协议吗?」.
The describes are protocol text, so they are corrected now. The wording
qualifies the promise, states the negative, names neither
`allowOrgOverride` nor the registry (no registry clause), and adds no
key. Its shape matches the sibling
`GetMetaItemsRequestSchema.organizationId` row reworded in PR objectstack-ai#16523, so
the four meta-read rows now read the same way.
- Scope ruling (comment 5807019811), maintainer, verbatim: 「16524
改文档,不需要按组织取」. This PR changes published text only: no runtime change,
and no refusal of a supplied `organizationId`.
- Measured correction (comment 5612706063): on
`GetMetaItemCachedRequestSchema` only the opening sentence overstated.
The ETag sentence ("Also folded into the ETag, so a scope switch never
returns a stale 304 from another scope's cached representation.") is
true, is pinned by PR objectstack-ai#17292, and is **kept byte-for-byte**. Its source
line does not appear in this diff.

## Premise re-verified on today's tree

Measured on `84880f9` (BASE); the merge of `369bcbed` touched no file
below.

- The gate is `organizationIdForMetaRead`
(`packages/metadata-core/src/meta-write-org-scope.ts:184`). It returns
`undefined` unless `declaresOrgOverride(type)` holds.
- `getMetaItem` (`packages/metadata-protocol/src/protocol.ts`) reads
`request.organizationId` only through the gate (`:8123`). A gated-away
value resolves `findDraft(null)` (`:8161`) and `findOverlay(null)`
(`:8227`-`:8228`), which are env-level.
- `getMetaItemLayered` gates at `:8633`. `if (orgId)` at `:8728` skips
the org overlay read entirely, and only `findOverlay(null)` runs.
- `getMetaItemCached` delegates to `getMetaItem` (`:12199`), so the same
drop applies. The ETag folds the *supplied* value (`:12294`), so the
kept ETag sentence stays true.

The runtime still behaves as the ruling describes, so no stop was
needed.

## Verification

All at head `3388f2920` (branch merged with `origin/main` `369bcbed`),
unless noted. Every heavy run went through
`scripts/pm/os-verify-lock.sh`, and each result is the `VERDICT
command-exit` line.

- `pnpm --filter @objectstack/spec build`, then `check:generated`:
before `--fix`, exactly 1 of 15 artefacts was stale
(`content/docs/references/**`). `--fix` regenerated that one, and the
three `organizationId` rows in `protocol.mdx` are the only lines that
moved. After the merge: "All 15 generated artifacts are up to date",
exit 0.
- `pnpm --filter @objectstack/spec test`: 540 files, 15820 passed and 2
todo, exit 0. `src/api/protocol.test.ts` alone: 178 passed.
- `pnpm --filter @objectstack/spec typecheck` (tsc, scripts, and the
test layer): exit 0.
- Compiled `dist` read back: the three new descriptions are present,
"allowOrgOverride" and "registry" each count 0 in them, and the ETag
sentence occurs verbatim once.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 101 commands, the same list before
and after the merge. `--ran` reconciliation reads "101 derived, 100 run,
1 NOT-MEASURED, 0 UNRUN". All 100 that ran exited 0.
- NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE
NOT MET). It needs every publishable package built: 33 still had no
`dist/` after the closure build below, and CI's `Build Core` runs it. As
a declared narrowing, I loaded every `require` entry of
`@objectstack/spec`, the only package this diff rebuilds, under CJS: 23
of 23 loaded. The gate's own `--list` names 18 spec entries, all inside
those 23. Every other package's dist is unaffected by string edits in
`packages/spec/src`.
- For five gates the first run refused with PREREQUISITE NOT MET
(unbuilt `lint` / `formula` / `client-react` / `objectql`):
`check:skill-examples`, `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:docs-transcript-drift` and
`check:lean-entry-closure`. After a turbo build of that 34-task closure
they ran green. `check-plugin-teardown-shape --self-test` first refused
on the shallow clone; it went green after a depth-1 fetch of its pinned
fixture commit.
- `check:dts-closure` and `check:sourcemap-no-sources-content` swept 1
built package, `@objectstack/spec`, which is the one this diff touches.
- ESLint (narrowed, a measurement rather than a skip): `eslint
--no-inline-config --format json` over the three touched paths returns 3
entries. `protocol.zod.ts` has 0 errors and 0 warnings. The `.md` and
`.mdx` answer "no matching configuration", which means they are outside
the population defined by `eslint.config.mjs`'s `files` globs. The
config enables no type-aware linting (no `parserOptions.project`, zero
`projectService`), so this diff cannot move a verdict on any untouched
file. The repo-wide `pnpm lint` is left to CI.
- No pin test is added. These strings are prose that no consumer parses,
and pinning their wording would only freeze the wording.

## Acceptance notes

- `packages/spec/src/api/protocol.test.ts:1599`-`1602`, a test comment,
still says the implementation "accepts and HONOURS `organizationId` on
all four read verbs: it selects the org partition … it decides which
tenant's row is served". That is the same overstatement, in an
unpublished test comment. It is outside this card's claimed file
surface, so I left it. Carrier: none.
- Draft PR objectstack-ai#19637 also regenerates
`content/docs/references/api/protocol.mdx`. Whichever PR lands second
regenerates that file; the file is routed to the `os-regen` merge
driver.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

1 participant