Skip to content

fix(metadata-protocol): judge a package publish against the batch's own pending declarations - #10389

Open
os-zhuang wants to merge 3 commits into
mainfrom
claude/issue-10377-publish-closure-datasets
Open

fix(metadata-protocol): judge a package publish against the batch's own pending declarations#10389
os-zhuang wants to merge 3 commits into
mainfrom
claude/issue-10377-publish-closure-datasets

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #10377

The defect

publishPackageDrafts built its author-time validation context from
engine.registry alone — the ALREADY-LIVE universe. A draft is not in that
registry (saveMetaItem's 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 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 carrying
dataset/shyx_customer_ds plus a dashboard whose widget binds it rolls back
at 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 dataset
could 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 — objects was in the
same state. flow→object publishes fine for a different reason:
validateFlowTriggerReadiness reports flow-trigger-unknown-object at
severity: 'warning', which does not refuse. validateWidgetBindings raises
widget-dataset-unknown at severity: 'error', and an error finding refuses
the 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

publishPackageDrafts reads its own pending drafts once, before any
promotion
, and folds them into all four context collections
(objects, permissions, books, datasets).

  • Read up front, not in the loop. A promote deletes the draft row it reads
    (repo.promoteDraft = active-row put + draft delete), so a body read after the
    first 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.
  • Replace by name, never append beside. Same rule
    buildRuntimeWriteSnapshots already applies to a write landing in its own
    context 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.
  • Additive only. A pending draft can make more names resolvable, never fewer.
    A name in neither the batch nor the live universe is still refused exactly as
    before — pinned.
  • Read failures propagate. The collection runs before Phase 1's transaction,
    so nothing has been written and the publish fails having changed nothing; every
    row read here is one promoteDraftForPublish is about to read again. Swallowing
    would silently shrink the closure and manufacture a refusal naming a
    declaration the author can see in their own package (ADR-0110 D3).
  • Absent, not empty. A batch with no closure-relevant drafts passes
    undefined and is byte-identical to the pre-change gate call.

CLOSURE_CONTEXT_KEY_BY_TYPE carries a satisfies Readonly<Record<string, keyof RuntimeStackContext>> clause: rename a collection key in
@objectstack/lint and this table stops compiling, rather than silently routing
a collection nowhere.

Audit of all four collections (fix requirement 3)

collection same-batch gap pinned by
datasets yes, refusingwidget-dataset-unknown (error) 2 tests, both draft orders
objects yes, advisoryflow-trigger-unknown-object (warning) 1 test
permissions yes, advisorysecurity-master-detail-ungranted (warning) 1 test
books no observable gap today carried anyway — see below

books is carried for uniformity, not for a measured symptom: both readers of
stack.books (validate-security-posture.ts) iterate books to produce findings
about 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 books must not inherit the gap.

The per-item door (#10291 coherence)

Read gh pr diff 10291 first, as dispatched. Its seam is onMetaItemPublished
— 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: publishMetaItem publishes
one item, so its "batch" is itself and a same-batch closure would be the written
item, which buildRuntimeWriteSnapshots already grafts into the candidate
snapshot. promoteDraftForPublish therefore accepts the new pending argument
and 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 (the
real saveMetaItem / publishPackageDrafts over 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: mergePendingDeclarations mutated to
return live unconditionally. Mutation confirmed on disk by grepping for the
injected marker (ABLATION-10377: 1) and for the deleted-path anchor
(supersededNames: 4, i.e. the body was bypassed rather than removed). No
rebuild 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 --porcelain empty
against the commit, 8/8 green.

Gate union re-derived after the final commit with
node scripts/pm/dispatch-gates.mjs (no paths) at 85a1e89df; every family it
named runs green. scripts/engine-double-contract.pinned.json gains the new
file's two rows via the gate's own --write, as it instructed.


Patch round — repo.get widened the repository-double contract

CI (Test Core 2/3) went red on the first commit: 9 cases in
packages/objectql/src/protocol-publish-package-drafts.test.ts — a sibling
package's suite — failed with TypeError: repo.get is not a function at
collectBatchPendingDeclarations, thrown from publishPackageDrafts before any
promotion
.

Cause. 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, 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 a
fact 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 (the
    live universe alone). Safe direction: the gate keeps judging and can only be
    more strict, never fail open.
  • All-or-nothing. Bailing out of the whole collection rather than skipping one
    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.
  • A read failure on a repository that has the member still propagates
    (unchanged): a miss and a fault are different facts (ADR-0110 D3).
  • 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 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 header
projection (it drops metadata on 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.get stub
listDrafts too, so a body-carrying projection would hand back headers there
anyway — degrading silently instead of degrading with a reason.

A second, self-caught regression. The new assertion's callback
(warn.mock.calls.map((c) => …)) inferred implicit any, one new raw tsc error,
which check:type-check-debt --re-measure caught as upward drift of a shrink-only
ratchet (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)

  • The exact CI-failing file: vitest run src/protocol-publish-package-drafts.test.ts
    in @objectstack/objectql17 passed. Full package → 224 files, 3939
    passed
    .
  • @objectstack/metadata-protocol127 files, 1734 passed | 10 skipped;
    @objectstack/lint77 files, 2137 passed.
  • Ablation of the guard, with the dist rebuild the cross-package path requires
    (objectql imports @objectstack/metadata-protocol by package name, so the
    mutation must reach dist/, not just src/). The condition was mutated to a
    never-true typeof comparison — an executable marker, after a first attempt
    using a comment marker was correctly refused by
    scripts/ablation-dist-preflight.mjs as "found ONLY in sourcemap files … treat
    this 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.
  • Gate union re-derived with node scripts/pm/dispatch-gates.mjs (no paths) at
    3495477a9; all named families green, plus the convention-triggered ratchet
    after 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

…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>
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol))
  • content/docs/releases/v17.mdx (via ObjectStackProtocolImplementation (symbol), publishPackageDrafts (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 7 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 fda3d67f99a14e1e3ccb8283ee9e4665ac3f1c84packageMentionDocs.

Which tree this was computed on

This run read content/docs from fb61c34b4015847574da7139762263a681d6f8b3 — the merge of head 3495477a9f25620c918278c1a7267f9a1d5b9c5f into base fda3d67f99a14e1e3ccb8283ee9e4665ac3f1c84, 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 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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 20, 2026
os-zhuang and others added 2 commits August 21, 2026 02:05
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant