Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): audit the batch package publish in Phase 2, outside the transaction (#8400) - #8605
Conversation
…utside the transaction `publishPackageDrafts` (Studio's "publish whole app") wrote no `sys_metadata_audit` rows at all — neither the allowed-outcome `publish` rows nor a `denied` row for a refusal. It calls `promoteDraftForPublish` directly, so #7748's row in `publishMetaItem` never ran for it. Both outcomes are written in Phase 2, after the ADR-0067 D2 transaction has closed: the allowed rows off `promoted[]`, the `denied` row from the rollback `catch`. Inside the transaction the refusal's own row would roll back with the batch it records, which is the defect #7748 closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…geset `check:error-code-casing` correctly flags `code: 'batch_aborted'` in a code position. Opted out per site with the gate's documented `adr0112-ok: D6b` marker rather than exempting the whole file, so the rest stays guarded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…cope The existing fixtures used ORG for the draft and for the publishing session, so an audit row keyed on either would pass. These two differ: an env-wide draft (organization_id IS NULL) published, and refused, by a non-null-org caller — the #3115 shape listDrafts surfaces via its $or. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
os-zhuang
commented
Aug 14, 2026
A — it is already the draft's own scope. And you were right that the fixture did not prove it, so it does now.
so So the asymmetry you were checking for does not exist. But your second point was the real one:
Correct, and it was unpinned in both directions — And I ablated them rather than trusting a green run, since a new fixture that cannot fail is worth nothing: Ablation 3 — key both rows on the caller's
Re-verified after the change on the merged tree: metadata-protocol 1271 tests passed (86 files, +2), PR body updated with Ablation 3. Generated by Claude Code Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8400
publishPackageDrafts— Studio's "publish whole app",POST /packages/:id/publish-drafts—promoted every draft in a package and wrote no
sys_metadata_auditrows at all. It callspromoteDraftForPublishdirectly rather thanpublishMetaItem, so #7748's row never ran forit: a batch that published twenty artifacts left the trail exactly as empty as a batch nobody
ran.
Placement, and why it is the fix
The card's placement argument is the ruling and I implemented it as written: Phase 2, after
the batch transaction commits, driven off
promoted[].publish/allowed, one per promoted itempromoted[]saveMetaItem,deleteMetaItemandpublishMetaItemall takepublish/denied, one for the causal itemcatch, outsideengine.transaction()Both rows carry
source: 'protocol.publishPackageDrafts', so the trail distinguishes "publishwhole app" from a single-item publish.
Three details that were not obvious going in:
listDraftssurfaces env-wide (organization_id IS NULL) drafts to a non-null-org callerand the promote targets the draft's own scope ([BUG] publish-drafts fails with no_draft after saving draft via Studio UI #3115). Keyed on the caller's org, the row
would record the publish against a partition the active row never entered.
promoted[]only carried
{ type, name }, so the scope is now captured explicitly asdraftOrgId.noteis wire-visible.auditMetaItemmaps it straight onto theGET /api/v1/meta/:type/:name/auditresponse, so the denied row carriesclientFacingFailureText, note.message— otherwise the driver dialect [finding]metadata-protocol's batch verbs still put caught error text on client-facing payloads — the 8 producers option C did not reach #8333 withheldfrom
failed[].errorwould reach a client through a second door. The full raw sentencestays in the existing
console.warn, where an operator reads it.code: 'batch_aborted'is one fixed value, not the lower-cased causal code. The auditcodecolumn is a documented closed set (ADR-0112 D6b). Lower-casing whatevererror.codehappened to abort the batch would turn it into an open set that grows with theerror catalog. The causal code rides in
note. The column's declared set insys-metadata-audit.object.tsis updated to include the new value, so declared stillequals written.
The stale comment in
publishMetaItemthat said the batch path "is therefore stillunaudited" is corrected in the same commit.
Anti-vacuity: how I know the harness persists rather than skips
The card names the trap — most multi-table fakes open
insertwithif (table === 'sys_metadata_audit') return { id: 'audit_skip' };, which makes every auditassertion pass for the wrong reason. Three things, not one:
protocol.lifecycle-audit-rows.test.tsand hasno
audit_skipbranch: the audit insert appends toauditRowsand mints a real id likeany other table.
save— publish, rollback and the 409 conflict denial never write a row #7748(
save) and asserts the landed row's full payload and that its id matches/^a_\d+$/—i.e. a real persisted row, not the sentinel. Under a revert of the production edits this
control must stay green, and it did.
transaction()really rolls back, whichis what makes the placement claim falsifiable at all (see below).
Attempted and landed are tracked separately —
auditAttemptsrecords every insert aimed atthe audit table and is deliberately excluded from the rollback snapshot. Best-effort semantics
(ADR-0010 §3.6) swallow a failed audit write, so without that channel "row missing" and "write
failed" are indistinguishable. Here it separates three states, not two: never attempted
(the defect), attempted-and-rolled-back (the wrong placement), attempted-and-rejected (a
provisioning fault).
Prediction vs measurement
Predictions were written down before either run
(
ablation-prediction.mdin the session scratchpad), test by test and assertion by assertion.Ablation 1 — revert the production edit, keep the tests
git checkout origin/main -- packages/metadata-protocol/src/protocol.tsPredicted 6 red / 2 green. Measured 6 red / 2 green. No divergence — including which
assertion fails first in each case:
Two predicted sub-facts worth stating because they are where a weaker test would have gone
vacuous:
expect(h.auditRows).toHaveLength(0)passes under the defect too.That assertion alone would be green forever. The failing one is the
auditAttemptscheck —under the defect nothing is attempted, so there is nothing for the failing table to reject.
success: false,publishedCount: 0,BATCH_ABORTED,case_gridstill a draft, zero allowed rows) all passunder the defect, correctly: they describe the rollback, not the audit.
Ablation 2 — the placement ablation
Ablation 1 proves an insert exists somewhere; it cannot tell the two sides of the transaction
apart. So the same insert, same payload, was moved into the Phase-1 closure and measured.
Predicted 2 red / 6 green. Measured 2 red / 6 green. No divergence:
The placement is load-bearing and measured, not asserted.
Ablation 3 — the scope ablation (added in review)
Both audit rows are keyed on the draft's own org, not the publishing session's
active org.
__batchItemis stamped asd, thelistDraftsrow, whose mapper isorganizationId: row.organization_id ?? null— the same sourcepromoted[].draftOrgIdcomes from, so the two outcomes agree by construction. But the original fixtures used
ORGfor the draft and the session, so a row keyed on either would have passed: thescope was unpinned in both directions.
Two fixtures now differ — an env-wide draft (
organization_id IS NULL) published,and refused, by a non-null-org caller, which is the #3115 shape
listDraftssurfacesthrough its
$or. Ablating the production code to key both rows on the caller'sorgId:Predicted 2 red / 8 green. Measured 2 red / 8 green. No divergence:
The other 8 stay green because in every one of them the draft's org and the caller's org
are the same value — which is exactly why they could not pin the scope and these two
were added.
What the tests do NOT pin, stated plainly
The allowed side's placement is under-determined by this suite, and that is not an
oversight in the tests — it is the card's own observation that for an allowed publish an
in-transaction row "is arguably right". Both placements produce the same observable result:
the batch committed, so a row written inside would have survived too. The reason it sits in
Phase 2 anyway is that one route must not audit its two outcomes under two different
durability rules; that argument is in the code comment, not in a red test.
The sharpest single measurement in the suite is inside the refused-batch case:
assertLockAllowsWritewrites itsitem_lockeddenial from inside the batch transaction,so it is attempted and then rolled away, while the
batch_abortedrow is written from thecatchand survives. One refusal, two audit writes, two different fates, decided entirely bywhich side of the transaction they sit on:
Verification
packages/metadata-protocol: 86 files, 1271 tests passedpackages/metadata-core: 10 files, 162 tests passedtypecheckgreen on both packages (source and test projects)check:nul-bytes,check:error-code-casing,check:test-source-alias,check:engine-double-contract,check:cross-package-test-inputs,check:durability-log-level,check:filter-alias-parity,check:query-options-erasure,check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:type-check-debtcheck:error-code-casinglegitimately failed first: the test assertscode: 'batch_aborted'in a code position. Fixed with the per-site
adr0112-ok: D6bopt-out the gate documents,rather than exempting the whole file — the rest of the file stays guarded.
The gate list was re-derived against the actual changed paths with
node scripts/pm/dispatch-gates.mjsafter the final commit, not taken from the dispatchprompt alone.
Adjacent defects, filed separately (not fixed here)
promoteDraftForPublishroll backwith the batch on this route. Measured by the new test (attempted, then gone). This PR's
batch_abortedrow means a refused batch is no longer silent, but the inner verdict's ownvocabulary and
lock_stateare still lost.so it is reached by neither of this PR's two sites and still writes nothing.
Generated by Claude Code
Generated by Claude Code