feat(sdk): expected item revisions and start-blocker admission as write-time preconditions

UN
2h ago
+5236-442

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.get report item_hash, the SHA-256 of the canonical item document. It equals the item's latest history after_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 with hashDocument(locked document) after the item lock is held and before anything is written. Every mutateItem caller, deleteItem and restore is guarded without per-command wiring. That includes annotations, linked files/docs/tests, dependency edits, bulk update-many rows 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.run take expectHash on 19 item-writing actions;
    • the SDK has withExpectedItemRevisions(expectations, op) and PmClient.withExpectedItemRevisions.
  • Refusal: item_revision_conflict (exit 4). It carries item_id, expected_revision, current_revision (the JSON envelope now renders these for every compare-and-set refusal) and details.changes_since, the newest 10 history entries since the declared revision with their top-level fields.
  • 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 in checked/unchecked.
  • Round-trip: --stdin-json accepts item_hash as a read-only envelope key, so pm get output still pipes back into pm update.
  • GH-1620: the commitWorkspaceTransaction JSDoc and docs/SDK.md now state that its lock serializes transaction coordinators only. A negative-control spec shows a peer write between inspect() and apply() 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 --start and start-task share one admission check, admitStartWithOpenBlockers. It is governed by governance.start_blocker_enforcement: strict (default), warn or off. The pm config key is governance-start-blocker-enforcement.
  • Blockers resolve through sdk/query/declared-blockers.ts. This is the resolution behind pm 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> --start pre-checks before the claim write, so a refused start leaves no claim.
  • The refusal, start_blocked_by_open_dependency (exit 4), lists details.open_blockers and gives an executable unblock command. I verified that command works. --force admits and records start_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_rewritten when 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 --background late refuses.
  • Partial-success commands:
    • validate --auto-fix and test-all refuse typed once every member was attempted.
    • close auto-unblock refuses with the dependent's conflict after the fan-out, and side_effects lists every write that committed first.
    • close-many / update-many rows report a committed close separately, with the dependent's conflict under dependent_revision_conflict.
    • Rollback rows and bulk history-compact / history-repair rows carry the typed conflict fields.
  • Recovery paths:
    • A guarded restore or history-repair --salvage-tail refuses 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).
  • Contracts:
    • get --fields item.item_hash still projects a custom metadata field of that name.
    • Inventory commands and deps refuse 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: warn and --force admit a start whose blocker record cannot be judged, recording start_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_files tools declare a top-level add that dispatch never hoists, so the call silently becomes a listing. The expected-revision unchecked receipt found it.
  • pm-4yxzvd (filed): --unset blocked_by deletes every blocked_by edge, 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-hash narrows 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) and pnpm 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.

Closes #1576
Closes #1620

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-json now report item_hash (the latest history after_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. In get --fields, a bare item_hash selects the revision while item.item_hash projects a stored metadata field of that name.
  • Guarding is opt-in via the CLI global --expect-hash, expectHash on the MCP/PmClient action inventory (including validate's item-writing modes), and the SDK withExpectedItemRevisions / readItemRevision; top-level and nested options placements 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 with expect_hash_invocation_rewritten (exit 2), and dispatch passes re-run the admissibility check so a pass that sneaks --background past an admitted foreground guard is refused. validate --auto-fix/--prune-missing re-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 nested options.id); other items need <id>=<item_hash>; a workspace custom field named expect_hash is routed through the repeatable field option because that placement is consumed as a guard. A file whose embedded id names another item refuses to bind or read with item_identity_conflict, judged against the physical file spelling (case-only differences still bind). Background test/test-all runs refuse with expect_hash_background_unsupported because the worker process can't reach the in-process scope.
  • Docs recommend taking the decision data and item_hash from one get() read — field-projected reads need item_hash in the selection — since a separate readItemRevision() could observe a newer revision than the decision data used. The workspace transaction coordinator invokes compensate() 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 on before_hash when the state's own after_hash uses another digest; evidence is best-effort, so malformed history still surfaces the typed refusal. Read and inventory commands (plus deps, which only reads) refuse --expect-hash (space-separated CLI subcommand paths classify as their native action), and expectations no write reached are reported as expect_hash_unchecked. Bulk rows — update-many/close-many forward and rollback, and history-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; its side_effects lists every committed write — the blocker's close and any dependent unblocked before the refused one — which close-many/update-many report as a closed row with auto_unblock_refused plus auto_unblocked warnings 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-tail rewrites 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, and start-task share one admission check governed by governance.start_blocker_enforcement (strict default, 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 --start proves 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 and claim_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-remove is suggested only when one literal row names the blocker and survives selector normalization, otherwise inspection is offered. --force admits. When a blocker record can't be judged (invalid document, unsupported format, ambiguous identity), strict without --force fails closed while warn and --force admit and record start_blocker_unverifiable.
  • Blocker resolution is extracted to src/sdk/query/declared-blockers.ts, shared with pm get's blockers facet; canonicalizing relationship kinds there also fixes a stored depends_on edge that previously threw.