What
publishPackageDrafts promotes every draft inside ONE engine.transaction() (ADR-0067 D2).
promoteDraftForPublish, called from inside that closure, writes its denial audit rows there:
assertLockAllowsWrite writes the item_locked / denied row, andrecordOptimisticConflictAudit writes the metadata_conflict / denied row.
On a transactional engine both roll back with the batch they are recording. The refusal is
what aborted the batch, so the row describing it is destroyed by the very rollback it caused —
and sys_metadata_audit ends up with nothing about a refused publish. That is the defect
class #7748 exists to close, surviving on this one route.
Evidence (measured, not inferred)
Pinned in packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts
(added by #8400), in the case named a refused batch leaves a denied row that SURVIVES the rollback it caused. Its harness has a transaction() that really rolls back, and it tracks
attempted inserts separately from landed ones:
expect(h.auditAttempts.some((a) => a.code === 'item_locked')).toBe(true); // attempted
expect(h.auditRows.some((a) => a.code === 'item_locked')).toBe(false); // and gone
So the write is genuinely made and genuinely undone. Both assertions pass on main today.
Why #8400 did not fix it
#8400 adds the batch route's own publish / denied row from the rollback handler, i.e.
outside the transaction, so a refused batch does leave a trail. That row carries
code: 'batch_aborted' and names the causal item, but it does not carry the inner
verdict's own vocabulary (item_locked / metadata_conflict) or its lock_state column —
a compliance query filtering on code = 'item_locked' still finds nothing for a package
publish refused by a lock.
Options for whoever takes this
- Have
promoteDraftForPublish return the refusal instead of auditing it inline, and let
each caller write the row on its own side of its own transaction (the single-item route
already writes outside one). - Buffer the inner rows and flush them post-rollback in
publishPackageDrafts.
Option 1 is contract-first and removes the "which caller's transaction am I inside" question
from the helper entirely; option 2 leaves the hazard in place and adds machinery around it.
Backlink: #8400 (the batch route's own rows), #7748 (the single-item routes).
Generated by Claude Code
What
publishPackageDraftspromotes every draft inside ONEengine.transaction()(ADR-0067 D2).promoteDraftForPublish, called from inside that closure, writes its denial audit rows there:assertLockAllowsWritewrites theitem_locked/deniedrow, andrecordOptimisticConflictAuditwrites themetadata_conflict/deniedrow.On a transactional engine both roll back with the batch they are recording. The refusal is
what aborted the batch, so the row describing it is destroyed by the very rollback it caused —
and
sys_metadata_auditends up with nothing about a refused publish. That is the defectclass #7748 exists to close, surviving on this one route.
Evidence (measured, not inferred)
Pinned in
packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts(added by #8400), in the case named
a refused batch leaves a denied row that SURVIVES the rollback it caused. Its harness has atransaction()that really rolls back, and it tracksattempted inserts separately from landed ones:
So the write is genuinely made and genuinely undone. Both assertions pass on
maintoday.Why #8400 did not fix it
#8400 adds the batch route's own
publish/deniedrow from the rollback handler, i.e.outside the transaction, so a refused batch does leave a trail. That row carries
code: 'batch_aborted'and names the causal item, but it does not carry the innerverdict's own vocabulary (
item_locked/metadata_conflict) or itslock_statecolumn —a compliance query filtering on
code = 'item_locked'still finds nothing for a packagepublish refused by a lock.
Options for whoever takes this
promoteDraftForPublishreturn the refusal instead of auditing it inline, and leteach caller write the row on its own side of its own transaction (the single-item route
already writes outside one).
publishPackageDrafts.Option 1 is contract-first and removes the "which caller's transaction am I inside" question
from the helper entirely; option 2 leaves the hazard in place and adds machinery around it.
Backlink: #8400 (the batch route's own rows), #7748 (the single-item routes).
Generated by Claude Code