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

Activity

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    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.


    Generated by Claude Code

  3. self-assigned this
    on Aug 14, 2026
  4. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor

    Claiming this card.

    • Session: session_012WMpuAfA2KSdDjGF6tm1bH
    • Branch: claude/issue-8594-denial-audit-survives-rollback
    • Worktree: /home/user/objectstack-issue-8594 (off origin/main @ 60cbf9d45d)

    protocol.ts region declaration (3-concurrent-editor cap): my region is
    promoteDraftForPublish / publishPackageDrafts audit 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 preflightViolations early 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

  5. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions