feat(sdk): expected item revisions and start-blocker admission as write-time preconditions
Write-time preconditions: expected item revisions and start-blocker admission
One PR for one family: a mutation refuses atomically when the state its decision was based on no longer holds.
pm-gh1576: expected item revisions (closes GH-1576, resolves GH-1620)
Read-decide-write agents and packages can now guard a write with the revision they decided on.
- Read side:
pm get/PmClient.getreportitem_hash, the SHA-256 of the canonical item document. It equals the item's latest historyafter_hash.readItemRevision(id)returns it without the rest of the read. - Check: the guard runs inside the locked item store (
core/store/item-mutation-precondition.ts). It is an AsyncLocalStorage revision scope, compared withhashDocument(locked document)after the item lock is held and before anything is written. EverymutateItemcaller,deleteItemandrestoreis guarded without per-command wiring. That includes annotations, linked files/docs/tests, dependency edits, bulkupdate-manyrows and extension commands that mutate through the SDK. - Multi-write commands: after a command's own write commits, its expectation advances to that revision, so a multi-write command (
claim --start) does not refuse itself. An interleaved peer write still refuses the next step. - Transports:
- the CLI global
--expect-hash <item_hash>binds to the command's<id>; use--expect-hash <id>=<item_hash>(repeatable or comma-separated) to name items explicitly; - MCP and
PmClient.runtakeexpectHashon 19 item-writing actions; - the SDK has
withExpectedItemRevisions(expectations, op)andPmClient.withExpectedItemRevisions.
- the CLI global
- Refusal:
item_revision_conflict(exit 4). It carriesitem_id,expected_revision,current_revision(the JSON envelope now renders these for every compare-and-set refusal) anddetails.changes_since, the newest 10 history entries since the declared revision with their top-levelfields. - Receipts:
- read commands refuse the flag with
expect_hash_requires_mutation; - malformed hashes refuse with
expect_hash_invalid; - an item the command never wrote produces the warning
expect_hash_unchecked:<id>:no_locked_write_reached_this_item, and the SDK reports it inchecked/unchecked.
- read commands refuse the flag with
- Round-trip:
--stdin-jsonacceptsitem_hashas a read-only envelope key, sopm getoutput still pipes back intopm update. - GH-1620: the
commitWorkspaceTransactionJSDoc and docs/SDK.md now state that its lock serializes transaction coordinators only. A negative-control spec shows a peer write betweeninspect()andapply()is lost without the guard and refused with it. - Flag budgets: each command's flag-lexicon budget grows by exactly the one inherited host flag (
SHARED_HOST_FLAG_GROWTH), with no spare allowance.
pm-urzubi: starting work respects open blockers
update --status in_progress,claim <id> --start,claim --next --startandstart-taskshare one admission check,admitStartWithOpenBlockers. It is governed bygovernance.start_blocker_enforcement:strict(default),warnoroff. Thepm configkey isgovernance-start-blocker-enforcement.- Blockers resolve through
sdk/query/declared-blockers.ts. This is the resolution behindpm get's blockers facet, extracted so the two cannot disagree. Terminal targets resolve; missing, unsafe and external references stay open. - The update path checks inside its locked mutate callback against the updated metadata, so an update that drops the blocker and starts in the same call is admitted.
claim <id> --startpre-checks before the claim write, so a refused start leaves no claim. - The refusal,
start_blocked_by_open_dependency(exit 4), listsdetails.open_blockersand gives an executable unblock command. I verified that command works.--forceadmits and recordsstart_blocker_override:<id>:<blockers>.
Review hardening
The Codex and CodeRabbit rounds made the guard hold on every path that writes an item, including the indirect ones:
- Extension rewrites: a guarded invocation refuses with
expect_hash_invocation_rewrittenwhen a parser or preflight override rewrites its tracker or arguments. Every final-dispatch parser pass is held to the same rule, and must keep the guard admissible: adding--backgroundlate refuses. - Partial-success commands:
validate --auto-fixandtest-allrefuse typed once every member was attempted.closeauto-unblock refuses with the dependent's conflict after the fan-out, andside_effectslists every write that committed first.close-many/update-manyrows report a committed close separately, with the dependent's conflict underdependent_revision_conflict.- Rollback rows and bulk
history-compact/history-repairrows carry the typed conflict fields.
- Recovery paths:
- A guarded
restoreorhistory-repair --salvage-tailrefuses over an unreadable live file, and salvage judges a fresh under-lock snapshot when its lock wait drifted. - An item file whose embedded id differs from its physical file name binds no revision (
item_identity_conflict).
- A guarded
- Contracts:
get --fields item.item_hashstill projects a custom metadata field of that name.- Inventory commands and
depsrefuse the flag. - CLI subcommand paths classify as their native action.
- Conflict evidence is best-effort, so malformed history never replaces the typed refusal.
- Start-blocker admission:
warnand--forceadmit a start whose blocker record cannot be judged, recordingstart_blocker_unverifiable:<id>:<code>.
Each fix has a spec that fails on the prior source. The per-round record is in pm-gh1576's comments.
Tracked follow-ups
- pm-wsqxwb (filed): the MCP
pm_comments/pm_filestools declare a top-leveladdthat dispatch never hoists, so the call silently becomes a listing. The expected-revisionuncheckedreceipt found it. - pm-4yxzvd (filed):
--unset blocked_bydeletes everyblocked_byedge, and the legacy dep-remove selector cannot match some stored rows, so the unblock hint falls back to an inspection step. - pm-r5yhxn (filed): history rewrites are not lease-fenced.
- pm-5igljl: records how
--expect-hashnarrows its retry race. Its atomic replacement remains open.
Verification
- New specs:
tests/unit/sdk/lifecycle/item-revision.spec.ts,tests/unit/sdk/lifecycle/start-blockers.spec.ts,tests/unit/cli/expected-revisions.spec.ts. - Full
node scripts/run-tests.mjs coverage(100% exact on src, 11,994 tests) andpnpm quality:static(mutation score 98.0) on the head commit. - Regenerated: contracts, error catalog, agent surfaces and SDK surface snapshot (additive changes acknowledged), plus the flag-lexicon gate.
Summary by Sourcery
Protect read-decide-write mutations with atomic revision checks and enforce declared blockers when starting work.
New Features:
- Add expected item revision preconditions across CLI, SDK, MCP, and item mutations, including revision reads, conflict diagnostics, and unchecked-write receipts.
- Add configurable start-work admission that detects open declared blockers across update, claim, and start-task flows, with strict, warning, off, and force-override modes.
Bug Fixes:
- Prevent stale read-decide-write operations from overwriting intervening item changes.
- Prevent blocked items from being started without an explicit override while preserving atomic refusal behavior.
Enhancements:
- Unify declared-blocker resolution between item reads and start admission.
- Clarify workspace transaction locking semantics and expose revision-guard APIs for transaction steps.
- Extend structured refusal envelopes with expected and current revision diagnostics.
Documentation:
- Document expected revision usage, conflict handling, transaction isolation, and start-blocker governance in the command and SDK documentation.
- Document the new start-blocker configuration setting and update the changelog.
Tests:
- Add unit and integration coverage for expected revisions, stale-write conflicts, transaction interleaving, blocker admission, configuration modes, and transport behavior.
Chores:
- Regenerate contracts, error catalogs, SDK surfaces, fixtures, and flag-budget metadata for the new interfaces.
Summary by cubic
Adds two write-time preconditions so mutations refuse atomically when the state a decision was based on no longer holds: expected item revisions for read-decide-write flows, and admission checking for starting work on items with open blockers.
Expected item revisions
pm get, SDK reads, and--stdin-jsonnow reportitem_hash(the latest historyafter_hash); the guard runs inside the locked item store before any write, covering annotations, linked files/docs/tests, bulk rows, extension writes, and test result recording. Inget --fields, a bareitem_hashselects the revision whileitem.item_hashprojects a stored metadata field of that name.- Guarding is opt-in via the CLI global
--expect-hash,expectHashon the MCP/PmClientaction inventory (includingvalidate's item-writing modes), and the SDKwithExpectedItemRevisions/readItemRevision; top-level and nestedoptionsplacements merge into one guard set, conflicting hashes for one id refuse at binding, and stripping a placement preserves the CLI mutation-input markers and stdin policy. The CLI records the typed invocation before extension parser and preflight overrides run: any rewrite of the tracker path or positionals — by a preflight override or either dispatch-time parser pass — refuses the guard withexpect_hash_invocation_rewritten(exit 2), and dispatch passes re-run the admissibility check so a pass that sneaks--backgroundpast an admitted foreground guard is refused.validate --auto-fix/--prune-missingre-raise the first collected revision conflict after all fixes are attempted, so a stale guard exits 4 instead of 0. A bare hash guards the command's<id>(including a nestedoptions.id); other items need<id>=<item_hash>; a workspace custom field namedexpect_hashis routed through the repeatablefieldoption because that placement is consumed as a guard. A file whose embedded id names another item refuses to bind or read withitem_identity_conflict, judged against the physical file spelling (case-only differences still bind). Backgroundtest/test-allruns refuse withexpect_hash_background_unsupportedbecause the worker process can't reach the in-process scope. - Docs recommend taking the decision data and
item_hashfrom oneget()read — field-projected reads needitem_hashin the selection — since a separatereadItemRevision()could observe a newer revision than the decision data used. The workspace transaction coordinator invokescompensate()without an expected revision, so a custom adapter must fence or reconcile its own compensation. - A stale write fails with
item_revision_conflict(exit 4) carrying expected/current revision and the newest 10 history entries, anchored onbefore_hashwhen the state's ownafter_hashuses another digest; evidence is best-effort, so malformed history still surfaces the typed refusal. Read and inventory commands (plusdeps, which only reads) refuse--expect-hash(space-separated CLI subcommand paths classify as their native action), and expectations no write reached are reported asexpect_hash_unchecked. Bulk rows —update-many/close-manyforward and rollback, andhistory-compact/history-repair— keep the typed conflict fields on refused rows. - Multi-write commands advance their own expectation after a committed step so they don't refuse themselves; a peer write between steps still refuses. test-all defers a revision refusal from result recording until every item has run.
close's auto-unblock fan-out finishes, then refuses with the first dependent's typed conflict; itsside_effectslists every committed write — the blocker's close and any dependent unblocked before the refused one — whichclose-many/update-manyreport as a closed row withauto_unblock_refusedplusauto_unblockedwarnings instead of a failed one. - Restore and history-maintenance commands check under their lock and re-locate the item after a rewrite; restores of deleted or vanished-file items judge against the tombstone revision and a guarded restore of an unreadable live item refuses rather than overwriting it.
history-repair --salvage-tailrewrites are judged under the salvage lock, and lock-wait drift is judged against a fresh under-lock snapshot. History rewrites hold a plain lock rather than a fenced lease, so a stalled rewrite can commit after a takeover (pm-r5yhxn, pre-existing); a storage override that transforms contents advances a guarded scope to its pre-override hash, so the scope's own second guarded write falsely refuses (pm-thbdxu, fails closed).
Starting work respects open blockers
update --status in_progress,claim --start,claim --next --start, andstart-taskshare one admission check governed bygovernance.start_blocker_enforcement(strictdefault,warn,off). The guarantee covers transitions into the start status only; an item already there isn't rechecked.- The update path checks against the updated metadata inside its locked callback;
claim --startproves blockers under its own claim lock and the composition's status write re-judges, so a blocker opened in between still refuses and the start releases its claim — fenced on assignee andclaim_principal(a reclaim by the same principal can't be fenced; claims carry no generation token). - Refusal is
start_blocked_by_open_dependency(exit 4), listing open blockers and an executable unblock command;--dep-removeis suggested only when one literal row names the blocker and survives selector normalization, otherwise inspection is offered.--forceadmits. When a blocker record can't be judged (invalid document, unsupported format, ambiguous identity), strict without--forcefails closed whilewarnand--forceadmit and recordstart_blocker_unverifiable. - Blocker resolution is extracted to
src/sdk/query/declared-blockers.ts, shared withpm get's blockers facet; canonicalizing relationship kinds there also fixes a storeddepends_onedge that previously threw.