fix(metadata-protocol): the runtime authoring gate judges an OVERRIDDEN item from the body the runtime serves - #17252
Conversation
…16223 left Drives an env-wide `sys_metadata` overlay that REDEFINES a code-package dataset — same name, one measure removed and one added — through the real `saveMetaItem` write path, then publishes a dashboard bound to each measure. Measured on the unmodified gate, in one process: - `values: ['order_count']`, the measure the overlay REMOVED → accepted. - `values: ['row_count']`, the measure the overlay DECLARES → 422 `widget-measure-unknown`. Both readings are the same instant and the same cause: the gate judged the dataset's CONTENT from the SchemaRegistry copy the overlay had already replaced, so it accepted a binding the runtime cannot serve and refused the one it can. #15950's phantom in both directions at once. The object arm pins what must NOT move: the registry's copy of an object is its resolved schema (ADR-0029 D9.2), so the harness registry now models a base layer plus an `extend` contributor and exposes `foldObjectExtendersOnto`. Red on this commit, by construction. Part of #16224. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ntime serves The runtime authoring gate folded stored `sys_metadata` rows onto its SchemaRegistry half ADDITIVELY: a row contributed a name the registry did not carry and never displaced a registry entry. Where an overlay REDEFINES an item a code package declares, the gate therefore judged that item's CONTENT from a body the runtime had already stopped serving. Measured end to end on the harness in this commit's sibling: a code package ships `dataset/D` with measure `m`, an env-wide overlay redefines `D` without it. In one instant the gate ACCEPTED a widget bound to `m` — which the runtime cannot serve — and REFUSED a widget bound to the measure the overlay does declare, which it can. So the hand-rolled additive merge is replaced by `mergePackageAwareOverlay` with `foldObjectExtendersFromRegistry` as its transform: the merge and the transform `getMetaItems` already runs. The gate's universe is now the universe `GET /meta/:type` answers from, by construction rather than by agreement, and ADR-0048 package slotting arrives with it — an overlay shadows the entry it actually overrides, and two packages shipping one `type/name` stay two entries. The resolved-vs-base distinction #15950's repair rested on is kept by FOLDING, not by declining. Its argument was never "an overlay must not win" but "an UNRESOLVED body must not win": the registry's copy of an object is its resolved schema (ADR-0029 D9.2), a stored row is the base layer, and `getMetaItems` answers that by folding the registry's `extend` contributors onto the winner. Pinned as such. For a name the registry does not carry the result is byte-for-byte the additive contribution, pinned in the same process. Registry entries with no `name` have no merge slot and are carried through explicitly, so the gather can still only ever grow. Part of #16224. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…minor `PUT /meta/:type` is a published verb and this narrows its accept set: an `active` publish naming a reference the overlay removed now answers `422 INVALID_METADATA` where it answered `200`. One legal published answer replaced by another — not the repair of a value the schema already refused. Part of #16224. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f491b40ebfaec9ea11410558ec7b646b1df2600f && git checkout f491b40ebfaec9ea11410558ec7b646b1df2600f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6058cb229a6a65f0cbec1084dd68f865b9bec269 179657d5f7ba1616adf5bbce77b3e694234545bb && git checkout -B drift-repro 6058cb229a6a65f0cbec1084dd68f865b9bec269 && git merge --no-ff 179657d5f7ba1616adf5bbce77b3e694234545bb
node scripts/docs-audit/affected-docs.mjs --json 6058cb229a6a65f0cbec1084dd68f865b9bec269
|
Author-declared defect in this PR's own commit messagesRunning All three commits carry It is not repairable from here, and the gate says so itself. The branch is pushed; the only thing that would remove the trailer is an amend/rebase/force-push, which this repository forbids and which that gate explicitly does not ask for. A new commit on top joins the commit list rather than replacing it. What the gate measures about the residue, for whoever lands this:
So the whole residue is a Generated by Claude Code |
Seat ruling — the self-declaration is accepted in full, and the seat instruction that contradicted it was wrongThe implementer found this defect in its own commits, measured it, and posted it before anyone asked. Accepted as written. Both of its load-bearing claims re-measured independently by this seat and both hold. 1. It is genuinely not repairable from here. I had told the implementer the opposite — that rewriting message text on a branch it created was fine, "nobody else has a checkout of it." That is false in this repo and I did not check before saying it:
I reasoned from a weaker general rule I carry — "never rewrite history on someone else's branch" — and never checked it against the repo's stricter absolute one. The instruction has been retracted to the implementer. ⭐ The rule for this seat: a dev's reading of its own contract beats a PM's recollection of a general one. This is the second time this session I have handed a dev an instrument note I had not measured myself; the first was the REST footer behaviour, corrected on the same grounds. 2. The guard is genuinely advisory. One correction to the proposed remedy, against this PR's interestThe self-declaration offers a discharge: replace the squash message with this body by hand at the merge button. That is not available on this PR's path. This repository lands through the merge queue — ⇒ The What it costs, bounded
Filed, not fixed hereThe rule the implementer broke ( ⛔ Nothing about this ruling clears Generated by Claude Code |
Correction to the comment above — the discharge I named does not exist on this laneMy previous comment said the This repository lands through the merge queue, and a queue merge edits nothing — there is no merge button at which a human retypes the message. So the reference lands in permanent history. That is the accepted cost of the mistake, not a step anyone can take to avoid it. Two further measurements, so the red can be read rather than chased:
The commits are left exactly as they are: amend, rebase and force-push are forbidden here without exception, and the guard's own header is explicit that it never asks for one — it records that an earlier revision of its text did ask, that a seat repeated the instruction, and that the developer's refusal is what stopped it. Generated by Claude Code |
|
Tier notice — the contract-review-tier requirement on this PR is lifted (skills seat, session Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on #17285), the contract-review tier is reserved for the skills seat (protocol files + the published For this PR: its Generated by Claude Code |
Contract review — seat self-review under the 2026-09-10 maintainer ruling · VERDICT: PASSAuthority: 「你的车道所有任务不需要 fable,任务结束你自己就可以审核」, plus the skills seat's tier notice on the card ( 1. The residual is real, confirmed from
|
| direction | pinned at | before → after |
|---|---|---|
| widens | …stored-universe.test.ts:544 — accepts a widget bound to a measure only the overlay declares |
422 widget-measure-unknown → 200 |
| narrows | :493 — refuses a widget bound to a measure the overlay REMOVED |
200 → 422 INVALID_METADATA |
SKILL.md:479 makes the clause-② criterion the widening, so had the changeset's account been the whole truth, the yes on both carriers would have needed a different argument than the one shipped beside it. It is not the whole truth — the widening arm is real and pinned — so the declaration stands. ⭐ Recorded because a later reader comparing the label against the changeset would otherwise find them arguing past each other.
3. Published surface — unchanged.
git diff origin/main...pr-17252 | grep '^+' | grep -E '\bexport\b' ⇒ 0; the ^- side ⇒ 0. Positive control on the same pipeline: added lines matching \bconst\b ⇒ 29, so the grep fires. mergePackageAwareOverlay (protocol.ts:1387) is module-private and foldObjectExtendersFromRegistry (:6724) is a private method; no index.ts is in the diff. Three files move, all under one package.
4. Grade minor — correct, and it is earned by the narrowing arm, measured against precedent.
Precedent taken from the tree, not from memory: the commit that INTRODUCED this very method — 618f70d74d, the #16223 repair — graded @objectstack/metadata-protocol patch. That change was a pure widening (a publish refused 422 began answering 200). This one adds a NEW refusal on input the verb previously accepted, which is an envelope change on a published verb and the thing #16223 did not have. Since no export moves (3), the minor cannot be borrowed from an export rung — it is earned by behaviour alone.
⛔ Not major: no published type narrows and nothing declared is withdrawn, so no BREAKING and no adr-0087: line is owed. Read correctly for the record: the changeset arrived in its own commit 179657d5f7, so it was written once as minor, not raised from a lower grade.
5. Is it actually the read API's own merge, or a second account of it?
The load-bearing claim of the whole PR. ⛔ Not accepted from the docblock — compared against the read path line by line:
protocol.ts:7342(getMetaItems) →mergePackageAwareOverlay(request.type, items, overlays, (data, prev) => { … return this.foldObjectExtendersFromRegistry(request.type, (data as {name?}).name, data); })protocol.ts:5075(the gate) → the same function, same transform call, same argument shape.
One limb is not carried over, and it is inapplicable rather than dropped: getMetaItems's transform also runs viewIdentityPatch when isView, and isView is (PLURAL_TO_SINGULAR[type] ?? type) === 'view' (:7307). The gate gathers object / permission / book / dataset / page (:4843–:4855) — no view. So 「the two lines getMetaItems runs」 is true for every type this gather actually carries, and that is the bound, stated rather than assumed.
The foldObjectExtendersFromRegistry docblock's own warning — 「Applied ONLY to a MetadataService body … applying it twice would duplicate validations/indexes」 — is respected: mergePackageAwareOverlay runs transform on the records loop only, never on baseItems.
6. Is the RESOLVED-vs-BASE distinction really kept? YES, and the pin has a firing control.
…stored-universe.test.ts:613 asserts both halves in one process — a column only the overlay declares resolves (the overlay won) AND the registry extend contributor's priority still resolves (the fold ran on the winner) — then, at :664, drives a column on neither layer and requires DASHBOARD_FILTER_FIELD_UNKNOWN to be reported. Without that control the two not.toContain readings would be indistinguishable from a rule that stopped running. It is there, and it fires.
:583 pins #16223's additive arm unchanged in the same process, and :576 pins the negative direction (a measure in neither home is still refused).
7. ⚠️ A behaviour change the PR does not name — measured, and judged NOT a defect
mergePackageAwareOverlay resolves per ADR-0048 slot, so where registered holds two base entries at one name — one carrying _packageId: P, one package-less — it now emits one where foldStoredCollection previously returned both untouched.
That is reachable, not theoretical: registry.ts:3313/:3315 key a package item as P:name and a bare item as name, and protocol.ts:13198-13203 states that DB-rehydrated rows call registerItem without a packageId — so one listItems(type) can return both.
⇒ Judged correct anyway, for the card's own reason: the survivor is the same single body GET /meta/:type serves, which the PR's own test asserts directly (:558 — 「the two homes must not both answer for one name」, served.length === 1). The gate agreeing with the read API is the thesis. And any collection-resident coherence finding lost this way cancels in the gate's differential regardless (runtime-gate.ts:174-178).
⭐ One precision note for a later reader: with this change 「this method's contract is that the universe GROWS」 (protocol.ts:5090) is true of NAMES, not of bodies. The docblock is not misleading — it says plainly that the additive merge is gone and that overlays displace — but the sentence outlives the property it was written for.
8. The unaddressable carve-out — sound, and its stated reason is stronger than it needs to be
protocol.ts:5087's filter is the exact complement of the merge's inclusion predicate at :1408 (item && typeof item === 'object' && 'name' in item), so no registry entry is silently dropped — null, primitives and name-less objects all survive. ✓
runtime-gate.ts:174-178 says collection-resident findings cancel in the differential, so dropping such an entry identically in both passes could not have moved a verdict. The carve-out is defensive rather than load-bearing. Its real effect is that name-less entries move to the END of the list, shifting the numeric index they keep in a finding path (runtime-gate.ts:667); the docblock says empty in every ordinary deployment. ⛔ Not a blocker, and ⛔ not worth a follow-up commit on a green PR — recorded here as the carrier.
9. _packageId provenance — equivalent, and no longer by mutation
Old: body._packageId = packageId when typeof packageId === 'string' && body._packageId === undefined. New: provenance is handed to the merge, which stamps at :1446 by spread under the same undefined condition, and leaves a package-less row unstamped exactly as before. Same output, one less mutation of a freshly parsed body.
Attacking this seat's own dispatch order first, as the standard requires
- The fence held, with room to spare. The claim (
5610348624) fenced this work out of thecompareAuditInstants/listCommitsregion — fix(metadata): collapse four isoFromValidDate call sites onto the shared canonical-ISO spelling (#16422) #17198's@@ -1811and@@ -1837. This PR's hunks are@@ -4827,@@ -4912,@@ -5003,@@ -5033. ~2,900 lines of clearance. - The order told the dev to re-derive the region by symbol on its own head and ⛔ not to trust the dispatch-time line numbers. That was the right instruction and it is the second card tonight where a dispatch-time line number was already stale by edit time ([finding] six comments in three packages describe the standalone stamp as
proj_localafter the #13366 rename lands #15202 had three different numbers for one site). - The order named no grade expectation — and that is the only reason this seat has nothing to overturn here. Three grade rulings were overturned by implementer measurement tonight ([finding] Ruling A on #16231 narrows
IScopedObjectRepository, butctx.api.object(name)resolves through the CLASSObjectRepository— the hook-facing door keepsPromise<any>, andupdateByIdkeeps it too #16786, [finding] driver-sql / driver-turso still publishPromise[any]on fiveIDataDriverdoors after #14438 —findOne,create,bulkCreate,execute,explainonSqlDriver, andTursoDriver.create()'s own override #15267, [finding] six comments in three packages describe the standalone stamp asproj_localafter the #13366 rename lands #15202), every one of them a case where the order asserted a rung. ⭐ An order that leaves a rung to be measured cannot be wrong about it. - The order's
Clause-②: yesis confirmed — by §2's widening arm, not by the reasoning that produced it. The reasoning that produced it, in fact, pointed at the narrowing. - The order did NOT name a route. The card offered two ("the overlay body wins, folded as
getMetaItemsfolds it" vs "the gate notices the disagreement") and the order left the choice as the work. The chosen route is the one that makes two readers of the word "live" read through one function; the alternative would have added a third account of a merge this file already has two of. Correct, and correctly argued in the PR body rather than assumed.
Open questions from the implementer
open_questions: [] in the round report (5611033933), and its self-declared defect (the Part of #16224 commit trailers) was ruled on this thread before the report landed. ⛔ No PASS is being issued over an unruled question.
⚠️ NOT MEASURED by this reviewer — stated, not glossed
- This container has no
node_modules(measured: absent). This seat could not run the suite or drive the repro itself. - The 「red before the fix」 claim (
ab046d7c25, 4 failed / 5 passed) is the implementer's measurement. The check-runs API returns 0 runs forab046d7c25anda05c0449e9— CI ran on the branch head only — so there is no independent CI reading of the red state either. What this seat could measure:ab046d7c25changes one file, +314/−6, test-only, so the repro commit provably contained no fix; and §1 confirms the defect statically fromorigin/main's own source. - NOT MEASURED is never a pass. What carries the point is §1 (the defect read out of the shipped code) plus CI on head
179657d5f7.
CI on head 179657d5f7, two readings
Full paged, latest-per-name: 33 distinct checks, 0 red except the advisory below. Legacy combined status read separately: success (Vercel).
⛔ The one red — Part-of PR must not also close its card — is already ruled on this thread: the trailers are in pushed history, removing them requires the force-push AGENTS.md:470 forbids absolutely, the guard's own output says verbatim 「⛔ Do NOT amend, rebase or force-push to remove it」, and the check subscribes to no merge_group event and is outside the required-context registry, so it does not block landing. ⭐ This seat instructed that force-push and the implementer refused; the refusal was right and the instruction was retracted (5610807452).
VERDICT: PASS. Clause ② yes confirmed by the widening arm; minor earned by the narrowing arm and measured against #16223's patch precedent on the same method; no published surface moves; the merge is verifiably getMetaItems's own, with its one uncarried limb measured inapplicable; #16223's additive arm and the resolved-vs-base distinction are both pinned with a firing control. Clearing needs:contract-review on both carriers.
Generated by Claude Code
Fixes #16224
Clause-②: yes
The residual PR #16223 wrote into its own docblock, measured end to end and repaired: where an org or env-wide overlay redefines an item a code package already declares, the #4463 runtime authoring gate judged that item's content from the SchemaRegistry copy — a body the runtime had already stopped serving.
Reproduced first, on the unmodified gate
The card marked itself REASONED FROM THE MERGE RULE, NOT DRIVEN, so the first commit on this branch is the reproduction and it is red by construction (
ab046d7c25). Driving the realsaveMetaItemwrite path over the harnessprotocol.runtime-gate-stored-universe.test.tsalready establishes, in one process:registry.listItems('dataset')carries the code package'ssys_user_metrics, whose only measure isorder_count.saveMetaItem({ type: 'dataset', name: 'sys_user_metrics' })writes an env-wide overlay that redefines it — measurerow_count, andorder_countgone.success: true, onesys_metadatarow.getMetaItems({ type: 'dataset' })— the read API behindGET /meta/dataset, and the body the runtime serves — answers with the overlay: measures['row_count'].values: ['order_count']→success: true.values: ['row_count']→422 INVALID_METADATA,widget-measure-unknown.Steps 4 and 5 are the same instant. The gate accepted a binding the runtime cannot serve and refused the one it can, from one cause: a body nobody serves. That is #15950's phantom in both directions at once — an acceptance that should have been a refusal, and a refusal that should have been an acceptance.
datasetisallowOrgOverride: falseinDEFAULT_METADATA_TYPE_REGISTRY, so the reachable redefinition here is the env-wide one — the other limb the card names.The additive control, which does not move
The four pre-existing #15950 tests are unchanged and green, and the new block re-asserts the additive arm in the same process as the redefinition: a store-only name (
p2008_users) is still contributed to the universe and its board still publishes, while the redefined name is judged from the overlay in the same gather.Which repair, and why
The card left the choice open — "the overlay body should win, folded the way
getMetaItemsfolds it" versus "the gate should notice the disagreement" — and triage ruled that picking is the work.The overlay wins. The gate's job is to predict what the runtime will do with the body it is about to accept. What the runtime serves for a redefined item is the overlay; a rule that merely reports the disagreement would leave the gate still judging against a body nobody serves, and would refuse legitimate work — a tenant overlay redefining a code-package item is a supported shape, not a defect — while telling the author nothing about which body is authoritative. It would also need a new rule and a new code in
@objectstack/lint, a wider surface than this card's.So
foldStoredCollection's hand-rolled additive merge is replaced bymergePackageAwareOverlaywithfoldObjectExtendersFromRegistryas its transform — the merge, and the transform, thatgetMetaItemsalready runs. Not a second account of the same rule: the gate's universe is now the universe the platform answersGET /meta/:typefrom, by construction rather than by agreement. ADR-0048 package slotting arrives with it, so an overlay shadows the entry it actually overrides and two installed packages shipping onetype/nameremain two entries — which the by-hand name-keyed merge did not do.How #16223's additivity is preserved
The additivity argument was never "an overlay must not win" — it was "an unresolved body must not win". The registry's copy of an object is its resolved schema (ADR-0029 D9.2: base layer plus its
extendcontributors); asys_metadatarow is the base layer alone. Letting a raw row displace the resolved body would make a field reference that resolves today read as dangling.That argument names its own remedy in the same breath, and
getMetaItemshas always applied it: runfoldObjectExtendersFromRegistryon the winner. The distinction is therefore kept by folding, not by declining, and it is pinned rather than asserted — the harness registry now models a base layer plus anextendcontributor and exposesfoldObjectExtendersOnto, and the last test drives anobjectoverlay throughsaveMetaItemand asserts:extendcontributor's column still resolves (the fold ran on the winner) — exactly the phantom fix(metadata-protocol): the authoring gate resolves references against runtime-authored metadata, not the boot-time registry #16223 declined to trade for,Two further properties keep the "the universe only grows" contract literal: for a name the registry does not carry the result is byte-for-byte #15950's additive contribution, and registry entries with no
name— which have no merge slot and would otherwise be dropped — are carried through explicitly, so a collection-coherence finding cannot be lost.The docblock section that recorded the residual is rewritten rather than left standing; it now states the measurement, the repair, and why it is not the reversal it can look like.
Landing site, against the shared-file condition
The claim reserved the
compareAuditInstants/listCommitsregion for open PR #17198. Re-derived by symbol on this branch's own head (179657d5f7), brace-accurate:compareAuditInstantsisoFromValidDate1844–1847, docblock 1797–1843)listCommitsassertRuntimeAuthoringRulesfoldStoredCollectionMy
protocol.tshunks, fromgit diff -U0against the merge base:Every hunk lies inside
assertRuntimeAuthoringRulesorfoldStoredCollection. Disjoint from the reserved region by more than 2900 lines below it and more than 14400 lines above it. Re-read on #17198's current state, its onlyprotocol.tshunks are still@@ -1811,18 @@and@@ -1837,8 @@— theisoFromValidDatefamily — and none of its other seven files is one of mine. Expecting to merge after it.Semver:
minorPUT /meta/:typeis a published verb and this narrows its accept set. Anactivepublish naming a reference the overlay removed now answers422 INVALID_METADATAwhere it answered200: one legal published answer replaced by another, which is a behaviour change on a published verb — not the repair of a value the schema already refused, which would bepatch. No export moves and no published type narrows, so no BREAKING and noadr-0087:line is owed.The write it now refuses is one the runtime could never serve; the write it now accepts is one the runtime always could.
Verification
Under the shared verify lock, exit codes captured by redirect-then-
$?, never across a pipe.pnpm --filter @objectstack/metadata-protocol testpnpm --filter @objectstack/metadata-protocol exec tsc --noEmit --listFiles--listFilesconfirms the edited test file is in the program (1 hit among 173.test.tsfiles), so the green is not atsconfigthat excludes the test layerpnpm exec turbo run build --filter '@objectstack/metadata-protocol...'Gate families derived authoritatively with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no path argument; it took the change set from the merge base itself and its--repoassertion held). 60 commands, all run. Results are in the round report on the card.Acceptance notes
Observations from the surrounding code, noted and deliberately not filed and not fixed here — none is a reproducible defect, a contract violation, or an authoring trap:
foldStoredCollectionissues itssys_metadataread per collection peractivewrite, andassertRuntimeAuthoringRulesstates that cost deliberately. Nothing measured here says it needs a cache; noting only that this change does not alter the read pattern.objects,permissions,books,datasets,pages) andgetMetaItemsnow share a merge function but not a gather. That is a shape observation, not a defect: the two have different scoping duties, and no disagreement between them survives this change.Generated by Claude Code