Skip to content

The lock/conflict denial audit rows roll back with the batch on publishPackageDrafts — a refused item in a package publish still leaves no trail #8594

Description

@os-zhuang

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, and
  • recordOptimisticConflictAudit 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

  1. 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).
  2. 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

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions