fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry - #14908

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence
Sep 3, 2026
Merged

fix(metadata-protocol): gate getMetaItem's overlay read on the metadata registry#14908
hotlong merged 3 commits into
mainfrom
claude/issue-14770-getmetaitem-overlay-precedence

Conversation

@os-musk

@os-muskos-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14770

getMetaItem — the singular /meta read verb — now resolves its own read scope through organizationIdForMetaRead, the registry-derived predicate the REST /meta doors have applied since #9454 and that #14683 (PR #14767) moved inside the plural verb.

⚠️ Fix round — the four blocking findings from the contract review

Landed as a2ff587d4, then merged with origin/main as c5c3f062e. The gate itself is unchanged: the review verified it against the plural door line by line and confirmed the precedence-not-union reading holds.

#findinghow it was cleared
B1CI red: protocol-meta.test.ts:89-112 expected the org row on appboth singular-verb fixtures re-spelled onto view; the vacuous sibling at :114-129 moved in the same edit and gained the assertion that makes it non-vacuous. Tests 95 passed (95), was 1 failed | 94 passed
B2changeset said the plural gate shipped "in the previous release"now reads "gated by #14683 / PR #14767, which lands in the SAME release as this change"
B3"the served document then vanishes at the next restart" is false for this doorcorrected in both places — PR body below and changeset. Nothing deletes the row; a restart drops the row's registry presence, which this door does not consult while a sys_metadata row answers
B4the "what moves" enumeration was asserted complete and was notthe fourth raw-org caller (runtime/src/domains/packages.ts:1239, applyPublishedSeeds) is named, in the body and in the code comment, together with the method that establishes the population

Advisory, also taken: A1 the write-side read/write-scope identity is qualified under the OS_METADATA_WRITABLE hatch; A3 the ADR-0029 D9 citation is now a quotation with its "Design only" status attached, not a paraphrase presented as a spelling; A4 the ADR-0005 decision block is credited with what it actually ranks. A2 ("six plugin-security sites" — actually five) lives on #14907, not in this PR, and was corrected there.

The defect, measured

At the merge base 84b8190ae, packages/metadata-protocol/src/protocol.ts:7348:

constorgId=request.organizationId;

spent by both overlay reads in the method, the second of which is at :7452:

constrecord=(orgId ? awaitfindOverlay(orgId) : undefined)??awaitfindOverlay(null);

⚠️ The card and its triage comment recorded this site at :7382-7383 and then :7453; on the merge base it is :7348 / :7452. Immaterial to the finding, recorded because the file keeps moving.

?? is precedence, not the plural verb's union. On getMetaItems the two queryByOrg reads are UNIONed, so an ungated organization can only ADD rows — the resurrection #14683 is about. Here it can SUBSTITUTE: on a type the registry declares allowOrgOverride: false, a pre-#6190 phantom org-scoped row — the kind loadMetaFromDb walks past and reportUnhydratableOrgScopedRows warns about — was served instead of the live env-wide document, to a caller that asked for the live one.

⚠️ Corrected (B3). An earlier draft of this paragraph ended "the served document then vanishes at the next restart". That is true of the sentence's source — the predicate's own docblock — and false of this door, for a measurable reason: nothing removes the phantom row. loadMetaFromDb filters organization_id: null (protocol.ts:20703-20705) and skips the rest; reportUnhydratableOrgScopedRows only console.warns (:21053); the sole engine.delete('sys_metadata', …) (:20552) is inside deleteMetaItem. So a restart did not clear the substitution — the same phantom was served again. What a restart drops is the row's registry presence, and this door does not consult the registry while a sys_metadata row answers.

The change

One line, plus the comment that carries the reasoning:

constorgId=organizationIdForMetaRead(request.type,request.organizationId);

Three properties, each deliberate:

⛔ Why precedence stays — the question Zone 2 C asked

The card's title names the read as "?? PRECEDENCE, not a union". That is the defect statement, and it is not a licence to convert the combinator. The repo settles this — but each citation only at its real scope, which the review corrected this section on:

⇒ The union question is answered against a union, with citations at their real weight, rather than left open. §2 of the new test pins it so a future reading of the title fails a test instead of landing.

The idempotence proof, discharged before the gate was written

The triage made the callee-side direction conditional on showing that moving the predicate inside changes no already-gating caller's scope. Let f(t, o) = organizationIdForMetaRead(t, o).

  • f(t, undefined) === undefined — the predicate returns early on an undefined organization, before it consults the registry flag. Every caller that names no organization reads exactly what it read before: rest/src/import-mapping.ts:66, rest/src/import-prepare.ts:416, rest-server.ts:8634, plugin-email's template read, service-analytics' draft probe, plugin-auth's metaReader. Test §3 sweeps this over the complete accepted-spelling population.
  • f(t, f(t, o)) === f(t, o)f answers o for an overridable type and undefined otherwise, so a second application cannot move it. Test §4 asserts it over the same derived population rather than a hand-listed sample.
  • The REST by-name door gates on the string this method folds to. It computes organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), readCtx?.tenantId) at rest-server.ts:5660 and then passes type: req.params.type, the raw segment; this method folds that segment with the same function, so request.type at the gate is the identical string the door gated on. Its cached arm reaches here through getMetaItemCached, which folds first and forwards the same hoisted readOrganizationId — the same no-op one hop later.
  • organizationIdForMetaWrite has a character-identical body, so the three internal write-side pre-reads — saveMetaItem's destructive-change probe, publishMetaItem's seed-loader adapter, publishPackageDrafts' build probes — now read the partition their write lands in. Read scope and write scope cannot disagree, which is the property Org-overridable metadata (view, dashboard) is accepted with a 200 state:'active' receipt but served by no read door #9454 chose this predicate for. ⚠️ Qualified (A1): that holds by default and deliberately not under the operator hatch — orgScopedWriteRefusal returns early when isOverlayAllowed is satisfied via OS_METADATA_WRITABLE, so a non-overridable type can still land an org-scoped write while the probe reads env-wide. That divergence is the hatch's own stated contract; its refusal message says it "unlocks the write, not the read", and the row it admits is exactly the kind boot hydration walks past.
  • Not achieved by denying everyone: §4's last case pins that a door which already resolved an organization for an overridable type still reaches the org partition.

⇒ Nothing double-scopes and nothing is wrongly denied.

⭐ What moves — four raw-org callers, with the method that establishes the population (B4)

An earlier draft asserted three and named one file. Corrected. Method:git grep -nE 'getMetaItem(Cached)?\s*\(' across the whole repo excluding tests, docs/, content/ and dist/ — 14 invocation sites — then each organizationId argument traced to its source and partitioned into "already organizationIdForMetaRead" versus "raw".

callertypewhy it moves
runtime/src/domains/meta.ts:703hard-coded 'object'allowOrgOverride: false — a caller that cannot be right about scope, by construction
runtime/src/domains/meta.ts:745hard-coded 'object'same
runtime/src/domains/meta.ts:768singularType off the URLmoves for the non-overridable half of what it serves
runtime/src/domains/packages.ts:1239 (applyPublishedSeeds, org from deps.resolveActiveOrganizationId at :472, forwarded at :492-494)'seed'also non-overridable, so its org-first attempt now reads the env-wide partition directly. It hand-rolls the same fallback as a second attempt (attempts at :1233-1235), so what it used to reach on the second try it now gets on the first

Every other invocation either names no organization at all, or is a REST door that already computed organizationIdForMetaRead — the idempotence legs above are what make those two cases no-ops. getMetaItemCached has exactly one non-test caller, rest-server.ts:5711, which is the gated cached arm.

All four are left as they are: the ruling was callee-side precisely so the next new caller needs no repair.

Tests

packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts — 14 cases, modelled on the plural door's pin.

  • §1 — the phantom-shadows-live-row case the card exists for. An env-wide object row and an org-scoped phantom of the same (type, name); a caller passing a raw active organization is served the env-wide document, and the phantom's partition is never read. Repeated across every non-overridable declared type, derived from the registry.
  • §2 — precedence preserved. For view, the org row still wins outright, and a key present only on the env-wide row does not appear in the served document — replacement, not a merge. Plus the ?? fallback chain when the org has no overlay of its own.
  • §3 / §4 — the two idempotence legs, over the complete META_URL_TO_SINGULAR ∪ registry-singular population.
  • §5 — the previewDrafts arm gets the same scope, both directions.
  • §0 pins the premises: the org-overridable set, and that object is not in it.

packages/objectql/src/protocol-meta.test.ts — the two singular-verb fixtures in per-organization overlay isolation move from app to view (B1).

app rolled back to allowOrgOverride: false in #6483, so once this verb gates, app never reaches the org partition at all. On app the first fixture asserted the phantom read this PR removes (and went red), and the second asserted a fall-through that could no longer fail. Both now run on view, the whitelisted specimen — the same re-spelling #6190 made one case up and #14683 made one case down — and each gained the call-level assertion its prose used to make only in a comment: the first pins that the env-wide partition is never queried once the org row answers (precedence, not a merge); the second pins that the org partition was queried before the fall-through.

The [#14683] note that predicted "Its two getMetaItem (SINGULAR) siblings above keep app on purpose: that verb is untouched here" is marked superseded in place, with the date and what falsified it.

Non-vacuity, measured rather than asserted (fix committed at a2ff587d4 first, then mutated, then restored):

legevidence
mutation reached disksecond fixture put back on app (row, request and the new call assertion together); injected app request form grep -c → 1, working blob d1f8da070 differs from the HEAD blob f612521db
result under mutationTests 1 failed | 94 passed (95) — and the failure is the call assertion, AssertionError: expected "vi.fn()" to be called with arguments, not the label assertion, which still passed. That is the vacuity itself, printed: on app the case cannot reach the org partition, so the fall-through it claims to measure is unmeasurable
restoregit diff HEAD empty, git status clean, working blob f612521db byte-identical to the HEAD blob
rebuild legnot needed for the mutation itself (it is in the test file, read from source), and the subject it resolves — @objectstack/metadata-protocol via exportsdist/ — was rebuilt at this commit before the run

The earlier ablation of the gate itself still stands from the first round: with const orgId = request.organizationId; restored, 4 failed / 10 passed — §1's three (expected 'org_acme showcase_task' to be 'env showcase_task', the phantom served) and §5's draft arm — while §2/§3/§4 stayed green, as predicted, since they do not depend on the gate.

Verification, at c5c3f062e (the final commit, origin/main merged in)

checkresult
pnpm --filter @objectstack/objectql exec vitest run267 files passed; 4593 tests passed, exit 0
… vitest run src/protocol-meta.test.ts95 passed (95), exit 0 — was 1 failed | 94 passed at 23a2b51c2
pnpm --filter @objectstack/metadata-protocol exec vitest run158 files passed, 2 skipped; 2349 tests passed, 10 skipped, exit 0
… vitest run src/get-meta-item-org-read-gate.test.ts14 passed (14), exit 0 — the pin is still 14/14
pnpm --filter @objectstack/metadata-protocol typecheckexit 0 — and tsc --noEmit --listFiles shows both the edited pin test and src/protocol.ts in the program (1 hit each), so this covers them
pnpm --filter @objectstack/objectql typecheckexit 0, including check:test-typecheckOK … 44 file(s) / 242 error(s) / 69 pinned signature(s), shrink-only ledger unmoved
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'71 successful, 71 total
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments)43 families, derived from the merged tree with no STALE TREE warning; 41 exit 0
check-test-completeness.mjs, pm/check-half-states.mjsexit 3 = PREREQUISITE NOT MET, recorded as NOT MEASURED on the gates' own instruction — the first needs a saved turbo run test log, the second needs repo-scoped REST egress this container does not have. Neither is a finding
check:dual-build-cjs-loads, check:type-check-debtexit 3 before the build, exit 0 after it — 102 require entry points across 66 packages load; 20 ledger entries re-measured, 405 raw errors, none above its recorded number
eslint . --no-inline-config (full repo, not narrowed)exit 0, no output
node scripts/pm/check-governed-merges.mjs --test on the final 4-file list0 of 4 path(s) hit the register — not governed
check:nul-bytes plus a manual control-byte scan of all four filesclean (grep -naP exit 1, no matches)

Exit codes were captured by redirecting to a file before reading it, never through a pipe. The gate union was run at this commit, and the two suites above were re-run on it after the merge.

Scope

Four files. packages/spec is untouched — the review confirmed it needs no change — including the organizationIddescribe() wording tracked separately on #14772, which is not addressed here and stays open.

Filed while working this, out of scope for this PR:#14907getMetaItemLayered is the same series' third instalment, reached ungated from runtime/src/domains/meta.ts:346. It is deliberately not folded in: its caller population is different, so this PR's idempotence proof does not cover it, and its orgId binds before the canonical fold, so this one-liner does not port. Its body's "six plugin-security call sites" was corrected to five — the actual non-test getMetaItemLayered( invocations are packaged-permission-set-lock-gate.ts:82 and permission-set-projection.ts:770, :938, :995, :1406; permission-set-overlay-discard.ts:243 only feature-detects and delegates.

Changeset

patch, on @objectstack/metadata-protocol — a behaviour correction on an existing published read door. Semver verified against b6c769019, 26f3588fb, 67ceb9aef, #14767's own changeset and AGENTS.md, and unchanged this round. node scripts/check-changeset-no-major.mjs and node scripts/check-empty-changeset.mjs both exit 0.

Two sentences moved this round: the plural gate is now credited to #14683 / PR #14767landing in the same release (verified on origin/main: .changeset/getmetaitems-org-read-gate.md is still present and unconsumed, packages/metadata-protocol/package.json is 17.2.0, and its CHANGELOG carries no #14683/#14767 entry), and the restart sentence is corrected as in §The defect, measured.

Review

⚠️needs:contract-review travels from the card to this PR and is cleared only at reviewer PASS — not by this seat. Left as a draft, auto-merge not armed, not marked ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…data registry
The singular `/meta` read verb applied no organization gate of its own: whatever
`organizationId` arrived was spent on whatever `type` arrived. Its two overlay
reads combine with `??` — precedence, not the plural verb's union — so an
ungated organization could SUBSTITUTE rather than merely add: on a type the
registry declares `allowOrgOverride: false`, a pre-#6190 phantom org-scoped row
was served instead of the live env-wide document.
Resolve the scope through `organizationIdForMetaRead` once, after the canonical
type fold, for both the active-overlay read and the ADR-0033 `previewDrafts`
read. ADR-0005's overlay-wins precedence is deliberately unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/data-modeling/drivers.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/schema-design.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/kernel/services-checklist.mdx(via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
  • content/docs/protocol/objectui/actions.mdx(via /:object/export (route, bridged from symbol getMetaItem — its registrar handler names it))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 9 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffe379404e79dda697e5e378fe2d8e7687ffddf1 — the merge of head c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf into base 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9, 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 ffe379404e79dda697e5e378fe2d8e7687ffddf1 && git checkout ffe379404e79dda697e5e378fe2d8e7687ffddf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf && git checkout -B drift-repro 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 && git merge --no-ff c5c3f062eeb5b7bf6405a715e2c7250ba1a420bf
node scripts/docs-audit/affected-docs.mjs --json 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9

⚠️ 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 3210b3d5da5fe0e87c1aabd74f0e117c805ab5b9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…verridable type
`getMetaItem` now resolves its own read scope through
`organizationIdForMetaRead`, so on `app` — `allowOrgOverride: false` since
#6483 — the org partition is never queried. The two `per-organization overlay
isolation` fixtures that exercised the singular verb on `app` were therefore
measuring the phantom read this branch removes: one went red, and its sibling
stayed green while asserting a fall-through that could no longer fail. Both move
to `view`, the whitelisted specimen, matching the re-spellings #6190 and #14683
already made in the same block, and each gains the call assertion that makes its
claim non-vacuous.
Prose corrections in the same edit, from the contract review:
- the changeset no longer says the plural gate shipped "in the previous
release" — both changesets are unconsumed, so both land in the same one;
- the phantom row is never deleted, so a restart does not clear the
substitution; what a restart drops is the row's registry presence;
- the "what moves" enumeration names four raw-org callers across two files,
with the method that establishes the population;
- the write-side read/write-scope identity is qualified under the
`OS_METADATA_WRITABLE` operator hatch, and the ADR-0005 / ADR-0029 citations
are narrowed to what those documents actually decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review (re-review after the fix round): PASS — head c5c3f062e, CONTRACT_REVIEW_TIER review by the director seat

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The engine seat's isolated re-review could not run (429, comment 5525655441 on #14770); this is the take-over the 2026-08-31 ruling reserves for the director seat on stall. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments

  • One line moves the contract:const orgId = organizationIdForMetaRead(request.type, request.organizationId), placed after canonicalizeMetaRequestType and spent by both the previewDrafts arm and the active-overlay arm. Verified in the diff: the ?? precedence read is untouched, and the gate sits after the fold — the two properties the earlier FAIL round checked line by line.
  • Accept set: unchanged. Public surface: unchanged (no new symbol, packages/spec untouched).
  • Behaviour change, bounded by the idempotence argument:f(t, undefined) === undefined and f(t, f(t, o)) === f(t, o) make every already-gated door and every org-less caller a no-op; what moves is a raw active organization handed to a non-overridable type, which now reads the env-wide row instead of a phantom. The four raw-org callers are enumerated with the method that produced the list (grep of every getMetaItem(/getMetaItemCached( site, argument traced) — the repair B4 asked for.
  • The fix round is verified against the diff, not the report. B1: both singular-verb fixtures in protocol-meta.test.ts moved from app to view and were strengthened in opposite directions — case 1 now asserts the env-wide partition is never queried once the org row answers, case 2 asserts the org partition was queried before the fall-through; the #14683 sentence they falsified is marked SUPERSEDED in place. B2: the changeset now says the plural gate lands in the same release. B3: the restart sentence is corrected in both places and re-derived (nothing deletes the phantom row). The vacuity was shown by mutation (label assertion passes, call assertion fails on app) — that is the right evidence.

② Semver vs changeset

@objectstack/metadata-protocolpatch — a behaviour correction on an existing published read door, no new symbol. Consistent with the four landed precedents the earlier review cited.

③ Boundary flags

getMetaItemLayered (#14907) is deliberately not folded in — different caller population, orgId binds before the fold, so the one-liner does not port. The organizationId describe wording (#14772) stays open. Both correct.

CI on c5c3f062e: every check green (36 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403, exit 3); the pair (PR + #14770) was read from the API by hand.

Landing:needs:contract-review stripped from this PR and from #14770, marked ready, auto-merge armed. Not governed (0 of 4 paths).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 14:26
@hotlong
hotlong enabled auto-merge September 3, 2026 14:27
@hotlong
hotlong added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit d5cbb44Sep 3, 2026
41 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-musk@hotlong@claude