fix(metadata-protocol): judge a package publish against the batch's own pending declarations - #10389
fix(metadata-protocol): judge a package publish against the batch's own pending declarations#10389os-zhuang wants to merge 3 commits into
Conversation
…wn pending declarations `publishPackageDrafts` built its author-time validation context from `engine.registry` alone -- the ALREADY-LIVE universe. A draft is not in that registry (the write-through runs on `mode: 'publish'`), and the batch's own promotions do not put it there either: `applyRegistryWriteThrough` lives in Phase 2, after the Phase-1 transaction in which every draft is gated and promoted. So while a batch was judged, no member of it was visible to any other member, in any order. Measured 2026-08-21 on a cloud rig: a package carrying `dataset/shyx_customer_ds` and a `dashboard` whose widget binds it rolled back at the dashboard with `[widget-dataset-unknown] ... does not resolve to a declared dataset`, on every attempt and under both dataset names the author tried. So a package shipping a dashboard together with its dataset could never publish. The batch door now reads its own pending drafts ONCE, before any promotion (a promote deletes the row it reads), and folds them into all four collections the closure carries -- `objects`, `permissions`, `books`, `datasets` -- replacing a live declaration of the same name rather than sitting beside it. A name in neither the batch nor the live universe is still refused exactly as before. Fixes #10377 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 — 7 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 fb61c34b4015847574da7139762263a681d6f8b3 && git checkout fb61c34b4015847574da7139762263a681d6f8b3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fda3d67f99a14e1e3ccb8283ee9e4665ac3f1c84 3495477a9f25620c918278c1a7267f9a1d5b9c5f && git checkout -B drift-repro fda3d67f99a14e1e3ccb8283ee9e4665ac3f1c84 && git merge --no-ff 3495477a9f25620c918278c1a7267f9a1d5b9c5f
node scripts/docs-audit/affected-docs.mjs --json fda3d67f99a14e1e3ccb8283ee9e4665ac3f1c84
|
…t declares no `get` Collecting the batch's pending declarations introduced the batch door's FIRST dependency on `repo.get`. `SysMetadataRepository` has always had it, but `getOverlayRepo` is the seam every publish double replaces, and the door's only previous body read -- the `seed` capture -- fires solely when a seed draft is in the batch, so a repository shape without `get` had never been asked for one. Measured: nine `publishPackageDrafts` cases in `@objectstack/objectql` drive a double declaring `listDrafts` alone, and the new read turned every one of them into `TypeError: repo.get is not a function`, thrown BEFORE any promotion -- the batch door dying on a shape it used to accept. Answered with a declared capability check rather than a wider `try`: a missing member is a fact about the repository, knowable up front, and it must not read like a failed read. A read failure on a repository that HAS the member still propagates. Degrading returns the closure to its pre-#10377 state (the live universe alone), which is the safe direction -- the gate keeps judging and can only be MORE strict, never fail open -- and it is all-or-nothing, so the verdict cannot depend on which org a draft happens to live in. The degrade SAYS WHY, once per process: which member was missing, where it was first reached, and that a same-batch sibling reference may therefore be refused. A silent fallback is the shape that reads as "clean" from every surface downstream. The batch's existing enumeration cannot supply the bodies: `listDrafts` is a declared header projection whose other caller is the console's pending-changes list, and the doubles that lack `repo.get` stub `listDrafts` too -- so widening it would degrade silently instead of avoiding the guard. Regression test pins that a minimal repo double publishes a batch and that the degrade is announced. Part of #10377 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new regression test read `warn.mock.calls.map((c) => ...)`, whose parameter tsc infers as implicit `any` -- one new raw error in the package, which `check:type-check-debt --re-measure` catches as an upward drift of a shrink-only ratchet (63 recorded, 64 measured). Fixed at the source, which is the author's remedy; the ledger entry is untouched. Part of #10377 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #10377
The defect
publishPackageDraftsbuilt its author-time validation context fromengine.registryalone — the ALREADY-LIVE universe. A draft is not in thatregistry (
saveMetaItem's write-through runs onmode: 'publish'), and thebatch's own promotions do not put it there either:
applyRegistryWriteThroughlives in Phase 2, after the Phase-1 transaction in which every draft is
gated and promoted. So while a batch was being judged, no member of the batch
was visible to any other member, in any order.
Measured consequence, reproduced as a unit test before anything was changed
(
Declared datasets: (none)in the refusal's own hint): a package carryingdataset/shyx_customer_dsplus adashboardwhose widget binds it rolls backat the dashboard with
[widget-dataset-unknown] dataset "shyx_customer_ds" does not resolve to a declared dataset. A package shipping a dashboard together with its datasetcould never publish, and neither renaming the dataset nor re-ordering the
items could help.
Premise check — the issue's root-cause read is correct, with one refinement
The issue attributes the gap to the closure "including objects but not
datasets", inferring that from flow→object publishing fine. Verified at source
and by test: no collection carried same-batch drafts —
objectswas in thesame state. flow→object publishes fine for a different reason:
validateFlowTriggerReadinessreportsflow-trigger-unknown-objectatseverity: 'warning', which does not refuse.validateWidgetBindingsraiseswidget-dataset-unknownatseverity: 'error', and an error finding refusesthe promotion — the batch being all-or-nothing (ADR-0067 D2), that refusal
aborts the whole package. So the gap was uniform; only its severity differed
per collection, which is exactly why it stayed invisible until an error-severity
rule landed on it.
The fix requirement is unchanged by that refinement, and the fix is the one the
card asked for: carry the batch's own pending drafts for every collection
the closure judgment carries.
The change
publishPackageDraftsreads its own pending drafts once, before anypromotion, and folds them into all four context collections
(
objects,permissions,books,datasets).(
repo.promoteDraft= active-row put + draft delete), so a body read after thefirst promotion may already be gone — the same reason the seed capture inside
Phase 1 reads before its own promote. Reading the whole set up front is also
what makes the closure order-independent by construction rather than by luck
of iteration.
buildRuntimeWriteSnapshotsalready applies to a write landing in its owncontext collection: a draft that edits a live declaration is one declaration in
two states, so appending would make an update read as a duplicate name.
A name in neither the batch nor the live universe is still refused exactly as
before — pinned.
so nothing has been written and the publish fails having changed nothing; every
row read here is one
promoteDraftForPublishis about to read again. Swallowingwould silently shrink the closure and manufacture a refusal naming a
declaration the author can see in their own package (ADR-0110 D3).
undefinedand is byte-identical to the pre-change gate call.CLOSURE_CONTEXT_KEY_BY_TYPEcarries asatisfies Readonly<Record<string, keyof RuntimeStackContext>>clause: rename a collection key in@objectstack/lintand this table stops compiling, rather than silently routinga collection nowhere.
Audit of all four collections (fix requirement 3)
datasetswidget-dataset-unknown(error)objectsflow-trigger-unknown-object(warning)permissionssecurity-master-detail-ungranted(warning)booksbooksis carried for uniformity, not for a measured symptom: both readers ofstack.books(validate-security-posture.ts) iterate books to produce findingsabout books, and nothing resolves a reference into the collection, so a
pending book draft's findings cancel in the gate's differential either way. It
is carried because the closure ruling judges a package as a unit and a
per-collection closure is precisely what produced this card — a future rule that
resolves into
booksmust not inherit the gap.The per-item door (#10291 coherence)
Read
gh pr diff 10291first, as dispatched. Its seam isonMetaItemPublished— the post-publish re-bind announce — and the draft-org-scope resolution; it does
not touch gate-context assembly, so the two doors stay coherent and no conflict
arises. The per-item door has no analogous gap:
publishMetaItempublishesone item, so its "batch" is itself and a same-batch closure would be the written
item, which
buildRuntimeWriteSnapshotsalready grafts into the candidatesnapshot.
promoteDraftForPublishtherefore accepts the newpendingargumentand the per-item caller states nothing — absence is the correct answer there, not
a gap.
Tests
New
packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts,8 cases, modelled on
protocol-publish-drafts-advisories.test.ts's harness (thereal
saveMetaItem/publishPackageDraftsover a faithful stub engine —nothing on the gate path is stubbed). Each collection gets a discriminating
"expects silence" case plus its boundary "still reports / still refuses" twin,
because a refusal-only assertion would also pass with the gate disabled.
Ablation, run from the committed state:
mergePendingDeclarationsmutated toreturn liveunconditionally. Mutation confirmed on disk by grepping for theinjected marker (
ABLATION-10377: 1) and for the deleted-path anchor(
supersededNames: 4, i.e. the body was bypassed rather than removed). Norebuild needed and none claimed — the subject is imported relatively from this
package's own
src, so the mutation is on the resolved path. Result 4 failed |4 passed: exactly the four discriminating cases (2 × datasets, 1 × objects,
1 × permissions) red, the four boundary cases green — the reported direction, not
a template's. Restore leg: marker absent (0),
git status --porcelainemptyagainst the commit, 8/8 green.
Gate union re-derived after the final commit with
node scripts/pm/dispatch-gates.mjs(no paths) at85a1e89df; every family itnamed runs green.
scripts/engine-double-contract.pinned.jsongains the newfile's two rows via the gate's own
--write, as it instructed.Patch round —
repo.getwidened the repository-double contractCI (Test Core 2/3) went red on the first commit: 9 cases in
packages/objectql/src/protocol-publish-package-drafts.test.ts— a siblingpackage's suite — failed with
TypeError: repo.get is not a functionatcollectBatchPendingDeclarations, thrown frompublishPackageDraftsbefore anypromotion.
Cause. Collecting the batch's pending declarations introduced the batch
door's first dependency on
repo.get.SysMetadataRepositoryhas always hadit, but
getOverlayRepois the seam every publish double replaces, and thedoor's only previous body read — the
seedcapture — fires solely when a seeddraft is in the batch. So a repository shape without
gethad never been askedfor one, and the new read silently widened what the door requires of it.
Fix — a declared capability check, not a wider
try. A missing member is afact about the repository, knowable up front, and it must not read like a failed
read:
typeof repo.get !== 'function'⇒ degrade to the pre-publishPackageDrafts validation closure omits same-batch dataset drafts: a package shipping dashboard+dataset together can never publish (widget-dataset-unknown) #10377 closure (thelive universe alone). Safe direction: the gate keeps judging and can only be
more strict, never fail open.
draft keeps the verdict from depending on which org a draft lives in — a
partial closure would resolve some of a package's own names and not others.
(unchanged): a miss and a fault are different facts (ADR-0110 D3).
was first reached, and that a same-batch sibling reference may therefore be
refused. A silent fallback reads as "clean" from every surface downstream, and
the regression test asserts the sentence, not just the success.
Why the batch's existing enumeration could not supply the bodies instead.
listDrafts— the read that defines the batch — is a declared headerprojection (it drops
metadataon purpose; its other caller is the console's"pending changes" list). Widening it would put every draft body on that listing,
and it would not even remove the guard: the doubles that lack
repo.getstublistDraftstoo, so a body-carrying projection would hand back headers thereanyway — degrading silently instead of degrading with a reason.
A second, self-caught regression. The new assertion's callback
(
warn.mock.calls.map((c) => …)) inferred implicitany, one new raw tsc error,which
check:type-check-debt --re-measurecaught as upward drift of a shrink-onlyratchet (
63 recorded, 64 measured). Fixed at the source — the author's remedy;the ledger entry is untouched, and the package is back at its recorded 63.
Patch-round verification (head
3495477a9)vitest run src/protocol-publish-package-drafts.test.tsin
@objectstack/objectql→ 17 passed. Full package → 224 files, 3939passed.
@objectstack/metadata-protocol→ 127 files, 1734 passed | 10 skipped;@objectstack/lint→ 77 files, 2137 passed.(objectql imports
@objectstack/metadata-protocolby package name, so themutation must reach
dist/, not justsrc/). The condition was mutated to anever-true
typeofcomparison — an executable marker, after a first attemptusing a comment marker was correctly refused by
scripts/ablation-dist-preflight.mjsas "found ONLY in sourcemap files … treatthis run as void". Mutation leg: preflight
✓ marker present in 2 built files,then the suite reproduced CI exactly — 9 failed, all
TypeError: repo.get is not a function. Restore leg: rebuilt, preflight--absent→✓ marker absent from all 22 built files, suite 17 passed,working tree clean against the commit.
node scripts/pm/dispatch-gates.mjs(no paths) at3495477a9; all named families green, plus the convention-triggered ratchetafter a full workspace build (
70 successful, 70 total):check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured … none above its recorded number.Generated by Claude Code