Repository navigation
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
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 14, 2026 Triage: lands in
packages/metadata-protocol(promoteDraftForPublish/publishPackageDrafts); rationale: measured defect — the denial audit rows roll back with the batch whose refusal they record, so the #7748 invariant (a refusal leaves a trail, in its own vocabulary) is broken on this route. Queued as Bug.- Serial constraints for the claiming seat: land after publishPackageDrafts writes no audit rows — Studio's "publish whole app" leaves the audit trail empty #8400 (in flight, same function and same test harness), and hard same-file serial with publishPackageDrafts' pre-flight refusal writes no audit row — a package publish rejected before the transaction leaves no trail #8595 (same
publishPackageDraftsbody) — never the same batch; suggest dispatching the pair to one dev in sequence. - PM mechanism assumption (falsifiable, not a ruling): option 1 — the helper returns the refusal and each caller audits on its own side of its own transaction — is the contract-first shape; the single-item route already writes outside a transaction. Dev should verify and report a fork if measurement disagrees.
Generated by Claude Code
- Serial constraints for the claiming seat: land after publishPackageDrafts writes no audit rows — Studio's "publish whole app" leaves the audit trail empty #8400 (in flight, same function and same test harness), and hard same-file serial with publishPackageDrafts' pre-flight refusal writes no audit row — a package publish rejected before the transaction leaves no trail #8595 (same
Claiming this card.
- Session:
session_012WMpuAfA2KSdDjGF6tm1bH - Branch:
claude/issue-8594-denial-audit-survives-rollback - Worktree:
/home/user/objectstack-issue-8594(offorigin/main@60cbf9d45d)
protocol.tsregion declaration (3-concurrent-editor cap): my region is
promoteDraftForPublish/publishPackageDraftsaudit emission. Checked the file's
other live claims before taking the slot — the ADR-0070 D1 gate region (#8361) and
toRowApiError(#8570, hunks around lines 1491-1552) are the other two; my region does not
overlap either.Scope boundary: #8595 (the pre-flight
preflightViolationsearly return above the
transaction) is a hard same-file serial and is not addressed here — I will not touch
that branch.Generated by Claude Code
Generated by Claude Code
- Session:
{ "issue": 8594, "status": "done", "branch": "claude/issue-8594-denial-audit-survives-rollback", "pr": "https://github.com/objectstack-ai/objectstack/pull/8627", "premise_still_valid": true, "summary": "Re-verified the card's evidence on the merged ref (origin/main @ 60cbf9d45d, with #8400's fda61e41 in): both quoted assertions still pass, so the baseline held. Measured the triage mechanism assumption rather than assuming it — the single-item route really does audit outside a transaction (the lock refusal is raised BEFORE repo.promoteDraft; the 409 is caught AFTER repo.put's own withTxn has unwound and rethrown) — so option 1 was taken as-is, no fork. promoteDraftForPublish no longer writes its denial rows: each refusal carries its audit row out as data on the thrown error, and each of its two callers records it on its own side of its own transaction (publishMetaItem in a catch, unchanged in effect and still filed under protocol.publishMetaItem; publishPackageDrafts from the rollback handler, outside the transaction, re-stamped protocol.publishPackageDrafts). A batch refused by a lock now leaves item_locked WITH its lock_state beside #8400's batch_aborted — two facts, two rows, neither replacing the other — and a refusal that reached neither gate (driver fault, NOT_OVERRIDABLE, INVALID_METADATA) still leaves exactly the one batch_aborted row it left before. No new code value minted (ADR-0112 D6b); the one new gate hit was a 'batch_aborted' comparison in a test, marked per-site with adr0112-ok: D6b per #8400's precedent. Mechanically two extractions plus a hand-off, so save/rollback/delete are byte-identical. #8595's pre-flight branch was NOT touched. Two surprises worth the PM's eye, neither requiring action: (1) the PR body carries TWO attribution footers — mine in session-URL form plus a platform-appended one; that is the platform, not another actor. (2) mcp__github issue_read get_labels on PR 8627 errored 'Could not resolve to an Issue with the number of 8627', so I could not read the label set back; skip-changeset does not apply here (a changeset IS present) and I applied no label, so nothing is pending on that step — but the read-back that normally closes it did not succeed.", "tests": "Target suite `protocol.package-publish-audit-rows.test.ts`: 15 passed (was 9 cases, now 15). Full `@objectstack/metadata-protocol`: 'Test Files 87 passed (87) / Tests 1278 passed (1278)'. Downstream consumers (PREFIX filter direction — dependents of @objectstack/metadata-protocol — rebuilt first for the dist-resolution trap): objectql 'Test Files 203 passed (203) / Tests 3579 passed (3579)', rest 'Test Files 115 passed (115) / Tests 1896 passed (1896)', runtime 'Test Files 155 passed (155) / Tests 2369 passed (2369)'. tsc --noEmit over the package: 63 errors, exactly the DEBT ledger's frozen count for @objectstack/metadata-protocol, unchanged; zero in either file I touched. REVERSE VERIFICATION, direction predicted before running, both ways. (a) revert production, keep tests — predicted RED on the deliverables, GREEN on the premise pin; measured 6 failed / 9 passed: \"expected [ 'batch_aborted' ] to deeply equal [ 'batch_aborted', 'item_locked' ]\", \"expected false to be true\" (the inverted pin), \"expected [] to have a length of 1\" (item_locked), \"expected undefined to be defined\" (the /audit read door), \"expected [] to have a length of 1\" (metadata_conflict), and the no-transaction() case red for a DIFFERENT predicted reason (the row already survived pre-fix with nothing to roll back, so it is the `source` that fails, not the existence). The premise pin and the driver-fault guard stayed green, as predicted. (b) over-broad, both shapes: forcing inTxn to a passthrough (\"nothing rolls back any more\") left every denial assertion GREEN and turned the ADR-0067 D2 controls RED — 3 failed / 12 passed, all three \"expected [ 'active' ] to deeply equal [ 'draft' ]\", which is the anti-vacuity proof the inversion needed; forcing recordPendingDenialAudit to mint an inner-vocabulary row with no verdict reached turned the driver-fault guard RED — 1 failed / 14 passed, \"expected [ … ] to have a length of 1 but got 2\". Both ablations ran on top of a COMMITTED fix, restored with `git checkout HEAD -- <the file>`; no git stash anywhere. GATES: check:error-code-casing (RED first — an unmarked 'batch_aborted' comparison — then green after the per-site D6b marker), check:nul-bytes (plus a grep -naP control-byte self-scan of all three changed files), check:cross-package-test-inputs, check:durability-log-level, check:filter-alias-parity, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:empty-changeset, check:changeset-gate-self-tests, check:objectui-changeset, check-changeset-no-major, check-adr-0087-registration — all exit 0. The list was re-derived against the ACTUAL changed paths with scripts/pm/dispatch-gates.mjs after the changeset existed; that re-derivation ADDED the .changeset family (check:objectui-changeset, check-adr-0087-registration, check-empty-changeset, check-changeset-no-major), which no pre-change list could have named. CI conclusions are the PM's to read — reported at draft-PR time, not waited on.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
- added a commit that references this issue
on Oct 7, 2026
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