fix(metadata-protocol): the authoring gate resolves references against runtime-authored metadata, not the boot-time registry - #16223
Conversation
…t runtime-authored metadata, not the boot-time registry `RuntimeStackContext` declares its collections as the LIVE declarations, and live metadata has two homes: the SchemaRegistry that code packages fill at boot, and `sys_metadata` that every runtime author writes to. The per-write gather read only the first, so a `PUT /meta/dataset` that answered 200 was invisible to the very next `PUT /meta/dashboard`, which refused each widget bound to it with a phantom `widget-dataset-unknown` until the process restarted. Measured on the card's shape in one process, no restart: the row is in `sys_metadata`, `GET /meta/dataset` returns six datasets, the registry returns the five code-package ones, and the board collects three phantom refusals. The store half is now folded onto the registry half for every context collection, additively (a stored row contributes a name the registry lacks and never displaces a registry entry, whose `object` bodies are the resolved base-plus-extenders shape a raw row is not), active rows only, in the write's own organization partition, with a failed read reported rather than degraded silently into a smaller universe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…limit`, and the ledger records its seams `check:objectql-double-limit` graded the harness's `find` double BLIND: it answered every row it matched however small a bound the caller passed, which reads exactly like a query that worked. The bound is now applied after the filter and by presence. `check:engine-double-contract` asked for the three seams this file pins (`delete`, `findOne`, `update`) to be recorded, so the ledger protects it: regenerated with `--write`, 3 rows added, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 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 56ccf7725d814ec35721a1056794cf9687446b88 && git checkout 56ccf7725d814ec35721a1056794cf9687446b88
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f5aec38a6af1679d258c27e13aa9d3e2a723ba11 7f8fd1b45b924a70ab7bce38f349633f16acd5c4 && git checkout -B drift-repro f5aec38a6af1679d258c27e13aa9d3e2a723ba11 && git merge --no-ff 7f8fd1b45b924a70ab7bce38f349633f16acd5c4
node scripts/docs-audit/affected-docs.mjs --json f5aec38a6af1679d258c27e13aa9d3e2a723ba11
|
Contract review (clause ②) — PASS — PR #16223 at head
|
Fixes #15950
What was measured, before anything was changed
Driving the real write path in one process, no restart between the steps, with
the five code-package datasets present in every read as the firing control:
saveMetaItem({ type: 'dataset', name: 'p2008_users' })success,state: 'active', onesys_metadatarowregistry.listItems('dataset')[sys_user_metrics, sys_organization_metrics, sys_session_metrics, sys_package_installation_metrics, sys_audit_log_metrics]— the authored one absentgetMetaItems({ type: 'dataset' })p2008_userssaveMetaItem({ type: 'dashboard' }), 3 widgets bound to it422 INVALID_METADATA, threewidget-dataset-unknown, hint"Declared datasets: sys_user_metrics, …"Those middle two lines are the defect in full: two readers of the word "live"
disagreeing about the same artifact in the same instant.
Where the seam actually is
The dispatch brief's candidate was
listCollectioninpackages/metadata-protocol/src/protocol.ts, marked reasoned-not-measured. Itis confirmed, and the mechanism behind it is now measured rather than
assumed: the gather is per-write and never cached, exactly as its comment says,
but its only source is
registry.listItems, and the registry is a boot-timeuniverse for every metadata type except
object.applyRegistryWriteThroughis why. Itsobjectbranch registersunconditionally; every other type falls to
if (this.environmentId !== undefined) return;, andhydrateOverlayIntoRegistryseparately declines any org-scopedrow on any kernel. So on the ordinary tenant posture a
PUT /meta/datasetthatanswers
200never reaches the registry, and stays invisible to the gate untila boot re-hydrates it — which is precisely why the card's step 5 (restart,
replay byte for byte) answered
200.packages/lintis not in breach and is untouched:runtime-gate.ts:165declares the field as "The live dataset declarations", and the reader that
was wrong is the one that filled it.
The repair
The gather now folds a stored half onto the registry half, in a new private
foldStoredCollection. Four properties, each chosen against a measuredalternative and stated in the method's own docblock:
and never displaces a registry entry. Not caution for its own sake: an
object's registry copy is its resolved schema (ADR-0029 D9.2, base plus
extendcontributors) while asys_metadatarow is the base layer alone —which is why
getMetaItemsrunsfoldObjectExtendersFromRegistrywhen its ownmerge lets an overlay win. Letting a raw row displace the resolved body would
trade this card's phantom for a subtler one.
ruling inverts: an author could publish a board satisfied by a dataset that is
not itself published. Pinned.
organization. No other org's overlays are visible to the gate on any kernel.
getMetaItemsapplies one; thecomment on it says in as many words that the registry primitives keep serving
a disabled package's items so "migrations, cross-package references and the
runtime authoring gate (
protocol.tsresolution context) still see a completeobject universe". Filtering here would make the stored half narrower than the
registry half it folds onto.
assertRuntimeAuthoringRulesbecomesasyncbecause a store read is. It isprivate, both call sites were already in async functions, and the publisheddeclaration does not move (measured below).
The degradation, which is the half Zone 3 asked about
"Never let context-gathering fail a write" still holds — nothing here throws.
What is not kept is the other half of the old
catch {}: degrading intosomething that reads like a smaller universe with nothing said. An unprovisioned
sys_metadatais the one benign case (isMissingTableError, this repo'sdeclared discriminator — the store genuinely holds no rows, so the registry half
is the whole answer). Any other failure is reported once, at
warn, namingthe consequence.
warnand noterrorper this repo's degradation rule: nowrite claims to have persisted anything it did not; the risk is a wrong verdict
on the next reference.
check:durability-log-levelis green on the result.The scope decision Zone 1.4 asked to be made deliberately
The other four arms were measured, not assumed, and the repair is uniform.
listCollectionserves five collections through one helper, so this is statedrather than slipped in:
objects— not defective in the common case. Its write-through registersunconditionally, so registry and store agree. The fold is a no-op for it
unless a write-through has failed (best-effort,
console.warn) or a rowarrived from elsewhere, where it is a repair.
pages— defective, same shape, lower severity. Measured, not guessed(triage asked for this reading and explicitly did not take it): a
runtime-authored page was invisible to
validateViewPageRefs, so a legitimatetype: 'page'view mount reportedview-page-unresolved. That rule reports atwarning, so the phantom rode inadvisoriesrather than 422-ing the write —which is why nobody had filed it. Pinned in both directions in this PR.
permissions/books— structurally identical (same helper, samewrite-through early return). Not separately driven end to end; the uniform fold
covers them and the full suite is green.
A datasets-only special case was rejected on the grounds that it would leave one
helper with two different meanings of "live", for no stated reason.
Cost
Five indexed
sys_metadatareads per active publish, issued together, neveron a draft. Bounded by the number of tenant-authored rows of one type —
code-package metadata lives in the registry and never reaches this read. The
comment that used to defend the cheap gather ("a registry map walk plus one
array copy") described only the first half and was the reason the second was
never taken; it now states the real cost and why it is paid.
Verification
All commands run at
7f8fd1b45, exit codes captured before any pipe.The pin —
protocol.runtime-gate-stored-universe.test.ts, 4 tests, green. Itdrives
saveMetaItemrather than building the gate's arguments by hand, becausethe arguments were the wrong thing:
runRuntimeAuthoringRulesis already pinnedboth ways against hand-built context in
@objectstack/lintand those pins couldnot see this defect and never will.
Ablation — the #7529 shape,
3 → 0. Fix reverted to the merge basebdc02182b(mutation proven on disk: blobd75be19bd→1168cae06, markercount
3 → 0), pin re-run:publishes a board bound to a dataset saved moments earlier→ fails,422,dashboards[0].widgets[0..2] [widget-dataset-unknown]— three phantoms,the card's number.
folds the store into EVERY context collection→ fails,view-page-unresolvedpresent: the
pagessibling, confirmed by measurement.still refuses a board bound to a dataset that exists in NEITHER home→passes both ways, correctly: it is the negative control and is not about
the fix.
Restored byte-exact — on-disk hash back to the
HEADblobd75be19bd,git diff HEADempty, marker count back to 3.Suites (
@objectstack/metadata-protocoland its two most discriminatingconsumers, downstream direction):
@objectstack/metadata-protocol@objectstack/objectql@objectstack/restpnpm --filter @objectstack/metadata-protocol typecheckgreen, andtsc --listFilesconfirms the new test file is in the checked set — the green isa reading about it rather than a statement about a population that excludes it.
Gates: the family derived from the real diff by
scripts/pm/dispatch-gates.mjs(57 families), all run. 54 green. Three did not produce a reading, and none of
the three is a red:
check:dual-build-cjs-loads— exit 3, PREREQUISITE NOT MET: reads builtoutput and 57 packages have no
dist/in this worktree. Nothing was measured.check:published-readme-exports— exit 3, same class, same reason.check:react-declaration-parity— exit 1, EXTERNAL_INPUT_REQUIRED: itsright-hand side is objectui's
sdui.manifest.json, which AGENTS.md records asan on-demand gate triggered by the objectui pin bump, not by CI.
Two gates found real problems in this PR's own new code and both are fixed here:
check:objectql-double-limitgraded the harness'sfinddouble BLIND — itignored the caller's
limitand answered with more rows than were asked for,which reads exactly like a query that worked. The bound is now applied after
the filter, by presence.
check:engine-double-contractasked for the three seams the new file pins(
delete,findOne,update) to enter the ledger. Regenerated with--write: 3 rows added, 0 lost.check:dts-closurewas re-run after building this package: its first greenswept 13 built packages and this one was not among them, so that green was a
statement about other packages. On the second run it swept 25, and the
package's own build asserts the same thing directly —
check-dts-emitted: @objectstack/metadata-protocol - 2/2 declared declaration file(s) present.A harness trap worth recording
The stub this harness is modelled on keeps one flat row map and skips
sys_metadata_auditby name. That was invisible for as long as nothing readsys_metadataas a table — and this change is the first thing that does. Adraft save writes a
sys_metadata_historyrow carrying nostate, so the flatmap served it back as an ACTIVE metadata row and a draft-only dataset resolved.
The draft test failed on exactly that before the harness was made table-scoped.
It is the kind of green that would have looked like the product accepting a
draft.
Clause ② — declared
yes, with the measurement that argues both halvesWidens the public surface: NO, measured.
@objectstack/metadata-protocolpublishes
dist, and its entire declaration surface isdist/index.d.ts+dist/index.d.cts(byte-identical to each other; no chunk.d.tsexists, so there is no shared-chunk blind spot here). Built at head,source reverted to the merge base, rebuilt, both files diffed — with the
rebuild proven to have really run by mtimes moving (
1788674388→1788674426→
1788674447) and the restored build reproducing the head hash080dc7faeexactly:
private foldStoredCollection;— a privatemember's bare name. Everything else added is JSDoc.
private assertRuntimeAuthoringRules;is byte-identical either side: itsreturn type moving from the bare
RuntimeAuthoringIssuearray to a Promise ofthat same array is invisible to a declaration, because TypeScript emits a
private member as a bare name with no signature.
That added bare-name line is also this instrument's firing positive control:
the diff is not empty, so a byte-identical result elsewhere would have been a
measurement rather than a blind spot. No exported symbol or signature moves.
Changes contract accept/reject behaviour: YES. A
PUT /api/v1/meta/dashboardthat answered
422now answers200. The lane criterion is "任何改变接受或拒绝行为的卡,不论多小" — any card that changes accept-or-reject behaviour, however
small — and
PUT /api/v1/meta/dashboardis a published contract face; the cardmeasured it against a
--prod-likedeployment.The counter-argument, stated so a reviewer can downgrade this cheaply. The
rejection being removed is a phantom: the lint contract already declares this
collection as the live declarations, so the accepted set of legitimate bodies
is unchanged and the implementation is moving into conformance with a contract
that already said this. That is the declared-≠-enforced class, and it is why
triage and the dispatch brief both read
no. This seat declaresyeson theerror-cost asymmetry — an over-declaration costs a review round and is visible,
an under-declaration crosses a guardrail invisibly — and because the criterion
is written to defeat "it is only a bug fix". If the reviewer judges the
restoration reading correct, the downgrade is one line.
needs:contract-reviewis hung on both carriers.Out of scope, filed rather than fixed
The additive merge leaves one residual, stated in the docblock and filed
separately: where an org overlay redefines a code-package item, the gate
still judges that item's CONTENT from the registry's version, so a board bound
to a measure the overlay removed would be accepted. That is a phantom in the
opposite direction, it needs the same extender-fold reasoning
getMetaItemsapplies, and it is not this card's.
Generated by Claude Code