feat: reopen verb + work_reopen tool; resolve on a closed item fails loud - #67
Conversation
…fails loud
A published resolution could not be corrected. Worse, trying to correct one
SUCCEEDED silently: `resolve` against an already-closed item checked only the
item's STATUS, exited 0, and echoed the OLD stored text back as if the new
text had landed. Seven wrong resolutions shipped that way
(model_performance-uma; spec `w3-uma-work-tracker-reopen/SPEC.md`).
resolve now compares the resolution TEXT at BOTH places it decides "did my
write land?" -- the normal post-write readback AND the contended path where
`_run` exhausted its retry budget. On an already-resolved target, checked
BEFORE any write so "NOTHING WAS WRITTEN" is literally true:
- identical text (CRLF/outer whitespace normalized) -> idempotent success,
payload carries `"idempotent": true`. Required, not a softening: the
shipped contention contract sells resolve's no-op as retry safety, and a
retry re-sends the identical string.
- divergent text -> BeadsError, nothing written, both texts side by side,
the words NOTHING WAS WRITTEN, and `reopen` as a runnable remedy.
`Beads.reopen` (+ CLI `reopen`, + `work_reopen`) is that remedy. It archives
the verbatim previous resolution and previous closed_at into the item's
attributed comment history BEFORE transitioning -- ordering that is
load-bearing, and now proven so: MEASURED against bd 1.1.2, a reopen CLEARS
close_reason. Had the wrapper trusted bd to keep it, every correction would
have destroyed the record it was correcting.
Also measured and corrected here: `bd reopen` leaves the OLD ASSIGNEE in
place, so a directed claim by anyone else is refused ("already claimed by
<old holder>") -- an item nobody can take is not a correctable item. reopen
clears it, as `release` already does.
Six new doctor assumptions fence all of it (33/33): reopen.reopens,
reopen.clears_closed_at, reopen.close_reason_disposition, reopen.emits_event,
resolve.divergent_text_refused, resolve.identical_text_idempotent.
The known cost is surfaced, not hidden: reopen clears closed_at, so a
corrected item re-lands on the correction date and throughput moves by one
item per correction (`closed_at_cleared`, `previous_closed_at`). That is why
the verb stays explicit rather than folded into resolve.
…evidence None absorbed silently, none re-dispositioned (contracts/ and VISION.md untouched, so CCV1-000's SYNC hashes still hold): - CCV1-009 (VIOLATION) -- the close path grew a pre-write read of its own and the fence moved into `resolve_outcome`. The probe's landmarks follow it and are now robust to formatting. Claim re-verified and UNCHANGED: the fence still runs only under `status == "held"`, so a post-reclaim close is still unfenced. - CCV1-015 (GAP) -- `_read_back_or_none` count 3 -> 4: `reopen` adopted verify-on-conflict. Real movement TOWARD the clause, recorded rather than absorbed. Still a GAP: take_custody/renew_custody remain uncovered. - CCV1-023 (GAP) -- the single-hold refusal is now asserted INCIDENTALLY in the modules suite (one branch of work_reopen's claim-degradation path). That suite is the one `make test` does not run, so the Freeze Bar clause is still unmet and the row stays GAP; the probe pins that exact file set so a real Fixture 4 still flips it. Lane artifacts under docs/lanes/f5c-work-tracker-reopen/ (artifact-path/v1): the DONE-NOTE, and raw evidence including the fail-before runs at parent commit 1621771 and the before-state demonstration of the silent discard.
Conflict was AGENTS.md's doctor-assumption count only (cli.py auto-merged; no source conflict). #67 said 33/33, #66's main said 28/28. The merged count is MEASURED, not computed: `doctor` on this merged tree reports **All 34 assumptions hold**. Arithmetic (33 + 2) would have written 35 and been wrong, which is why the number was read off the tool rather than added up. Manager merge-mechanics only -- no source file changed.
Manager verification (independent, scratch clone) — and one manager-authored merge commitVerified in a fresh clone of Classification: FIX (with a required remedy path), not a bare featureThe Fail-before, reproduced
matching the lane's claim, and including All four documented tiers, measured after the merge
Both failures independently confirmed pre-existing at parent One failure the lane did not report appeared in an early run — The merge commit (
|
- adapter.py Beads.resolve_outcome: combined the fence lane's custody- IDENTITY fence (three branches, including the post-reclaim one outside the `status == "held"` gate) with PR #67's already-resolved rule (text-identical idempotent no-op / divergent-text fail-loud) -- ONE pre-write read now serves both preconditions, fence first, then the already-resolved check, both fully before any write; the existing contended-path read-back (PR #63) is unchanged and still wins for a phantom-conflict recovery whose own text already landed. - adapter.py Beads.reopen: routed through `_verified_write` (two calls -- the `bd reopen` write itself, and the conditional stale-assignee clear), joining every other item-level write verb (CCV1-015); the "reported success but still resolved" phantom case keeps its own precise wording by being detected in the write step rather than the generic verify failure, so `tests/unit/test_reopen_preconditions.py` needed no changes. - ledger/rows.yaml + ledger/checks/test_custody_rows.py: kept every wave-1 row flip and probe rewrite (CCV1-009 CONFORMS via the identity fence, CCV1-015 CONFORMS via `_verified_write`), folded in PR #67's reopen coverage (CCV1-015's verb list + `_read_back_or_none` call-site count, now 5: definition + resolve + release + reopen's two verifies), and re-pointed CCV1-009's probe at `resolve_outcome` (the fence's new home) and its pre-write boundary at the `close` write (since the merged method's own pre-write read also opens with a `try:`). - context/awareness.md / cli.py epilog: kept the fence lane's corrected custody prose (renewal is one-strike, TTL is sweep-enforced) and PR #67's reopen documentation (item 7 / the resolve-on-closed text-compare rule) side by side, with no restatement of the read-back guarantee that now appears earlier in both documents. - modules/tool-work-tracker/__init__.py: auto-merged cleanly -- `_release_after_failed_custody` (custody-atomic) and `WorkReopenTool` (PR #67) coexist; `resolve` already called `resolve_outcome`. - Added test_reopen_verifies_by_readback_when_the_wrapper_reports_conflict and its genuine-failure negative to tests/integration/test_write_readback.py in house style, per CCV1-015's new reopen coverage.
…esolution MEASURED (bd 1.1.2, 2026-09-03, throwaway project via the sanctioned CLI): `defer` or `block` on an already-resolved item exited 0, moved it out of `resolved`, and BLANKED its stored `resolution` -- destroying the official, already-published record with no warning, no confirmation, no archive and no trace of what it used to say. The remaining verbs (`--clear` -> `claim` -> `resolve`) then rewrote that record end to end using nothing but sanctioned calls, which is why two prior lanes' "a closed resolution is unwritable through every sanctioned path" claim was false. This is strictly worse than the defect `resolve`'s divergent-text refusal closes: that one discarded the text you SEND; this discards the text already STORED. And it sat one verb away from `release()`, whose docstring goes to deliberate lengths to make exactly this transition "structurally impossible from this path". The guard lives in `_set_status_with_reason` -- the single shared implementation both verbs go through, so there is one guard that cannot drift between them -- and is checked BEFORE any write, which is what makes the refusal's own "NOTHING WAS WRITTEN" literally true. It echoes the text at risk and points at `reopen` (shipped by #67), the SAFE door to the same place: it archives the previous resolution and closed_at into an attributed comment first. The unsafe door closes; the safe one stays open. - adapter.py: `_status_change_on_resolved_error`, `_STATUS_CHANGE_VERB`, the pre-write guard, docstrings on both verbs. - contract.py: `defer.refuses_resolved` / `block.refuses_resolved`, asserted separately per verb on purpose -- a future change that gives `block` its own path cannot leave one door open while the other check keeps passing. Each asserts all four properties, including the one that actually protects a record: the stored resolution is unchanged byte for byte. - tests: integration (refusal x2, the whole loop, `reopen` still archives, and 6 "non-resolved items unaffected"), cli (exit code + record intact), modules (`success=False` on the agent-facing surface). - AGENTS.md: doctor count 34 -> 36, MEASURED from `doctor`, not computed. Refs: model_performance-2nx
…esolution MEASURED (bd 1.1.2, 2026-09-03, throwaway project via the sanctioned CLI): `defer` or `block` on an already-resolved item exited 0, moved it out of `resolved`, and BLANKED its stored `resolution` -- destroying the official, already-published record with no warning, no confirmation, no archive and no trace of what it used to say. The remaining verbs (`--clear` -> `claim` -> `resolve`) then rewrote that record end to end using nothing but sanctioned calls, which is why two prior lanes' "a closed resolution is unwritable through every sanctioned path" claim was false. This is strictly worse than the defect `resolve`'s divergent-text refusal closes: that one discarded the text you SEND; this discards the text already STORED. And it sat one verb away from `release()`, whose docstring goes to deliberate lengths to make exactly this transition "structurally impossible from this path". The guard lives in `_set_status_with_reason` -- the single shared implementation both verbs go through, so there is one guard that cannot drift between them -- and is checked BEFORE any write, which is what makes the refusal's own "NOTHING WAS WRITTEN" literally true. It echoes the text at risk and points at `reopen` (shipped by #67), the SAFE door to the same place: it archives the previous resolution and closed_at into an attributed comment first. The unsafe door closes; the safe one stays open. - adapter.py: `_status_change_on_resolved_error`, `_STATUS_CHANGE_VERB`, the pre-write guard, docstrings on both verbs. - contract.py: `defer.refuses_resolved` / `block.refuses_resolved`, asserted separately per verb on purpose -- a future change that gives `block` its own path cannot leave one door open while the other check keeps passing. Each asserts all four properties, including the one that actually protects a record: the stored resolution is unchanged byte for byte. - tests: integration (refusal x2, the whole loop, `reopen` still archives, and 6 "non-resolved items unaffected"), cli (exit code + record intact), modules (`success=False` on the agent-facing surface). - AGENTS.md: doctor count 34 -> 36, MEASURED from `doctor`, not computed. Refs: model_performance-2nx
…esolution (#72) * fix: `defer`/`block` refuse a RESOLVED item instead of blanking its resolution MEASURED (bd 1.1.2, 2026-09-03, throwaway project via the sanctioned CLI): `defer` or `block` on an already-resolved item exited 0, moved it out of `resolved`, and BLANKED its stored `resolution` -- destroying the official, already-published record with no warning, no confirmation, no archive and no trace of what it used to say. The remaining verbs (`--clear` -> `claim` -> `resolve`) then rewrote that record end to end using nothing but sanctioned calls, which is why two prior lanes' "a closed resolution is unwritable through every sanctioned path" claim was false. This is strictly worse than the defect `resolve`'s divergent-text refusal closes: that one discarded the text you SEND; this discards the text already STORED. And it sat one verb away from `release()`, whose docstring goes to deliberate lengths to make exactly this transition "structurally impossible from this path". The guard lives in `_set_status_with_reason` -- the single shared implementation both verbs go through, so there is one guard that cannot drift between them -- and is checked BEFORE any write, which is what makes the refusal's own "NOTHING WAS WRITTEN" literally true. It echoes the text at risk and points at `reopen` (shipped by #67), the SAFE door to the same place: it archives the previous resolution and closed_at into an attributed comment first. The unsafe door closes; the safe one stays open. - adapter.py: `_status_change_on_resolved_error`, `_STATUS_CHANGE_VERB`, the pre-write guard, docstrings on both verbs. - contract.py: `defer.refuses_resolved` / `block.refuses_resolved`, asserted separately per verb on purpose -- a future change that gives `block` its own path cannot leave one door open while the other check keeps passing. Each asserts all four properties, including the one that actually protects a record: the stored resolution is unchanged byte for byte. - tests: integration (refusal x2, the whole loop, `reopen` still archives, and 6 "non-resolved items unaffected"), cli (exit code + record intact), modules (`success=False` on the agent-facing surface). - AGENTS.md: doctor count 34 -> 36, MEASURED from `doctor`, not computed. Refs: model_performance-2nx * fix(adapter): defer/block REFUSE a resolved item instead of silently blanking its resolution model_performance-2nx (CRITICAL). Measured on a throwaway project: resolve an item with 'ORIGINAL TEXT', then `defer` -> exit 0, `block` -> exit 0, and the stored resolution is GONE. The full loop block -> clear -> claim -> resolve then completes with no bd involvement at all, rewriting a closed record's official text with no archive, no warning and no trace of what it used to say. Two consequences the measurement settles: (A) The premise of model_performance-uma and -44f was WRONG. Both assert a closed item's resolution is unwritable through every sanctioned path. It was writable, through work-tracker verbs alone. 44f's own summary table lists work_defer/work_block as 'status/location only -- no' against `resolution`; that row was wrong on both counts. (B) It is WORSE than the defect uma was filed for. uma's discards the text you SEND; this discards the text already STORED -- the official record -- at exit 0. adapter.release() goes to deliberate lengths to make reopening a closed item 'structurally impossible from this path' while defer/block did exactly that, destructively, one verb away. THE FIX: defer() and block() refuse when status is resolved, writing nothing, naming the item and its status, and pointing at `reopen` (shipped by f5c, 2468a69) as the sanctioned archiving path. Mirrors release()'s existing refusal discipline. Two doctor assumptions -- defer.refuses_resolved, block.refuses_resolved -- so it cannot regress silently. TESTS, all four tiers: unit 789 passed; integration+cli 31 passed (the new test_defer_block.py + test_cli_new_verbs.py); modules 9 passed; ledger 24 passed; ruff check + format clean. tests/unit/test_supervisor_web.py is the known PORT-BINDING FLAKE, not a regression here: it fails intermittently (fail/pass/fail across three runs of the same file on this tree), its failing test's identity varies, and this branch does not touch supervisor.py at all. RECOVERED BY THE MANAGER: the lane died markerless at ~00:40 mid 'commit and push'. The work was complete and uncommitted in its worktree; this commit is that work, unchanged, with the tiers re-run to confirm before publishing. --------- Co-authored-by: agent-2nx-lane <agent@localhost> Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
The defect
A published resolution could not be corrected. Worse, trying to correct one succeeded silently:
resolveagainst an already-closed item checked only the item's status, exited 0, and echoed the old stored text back as if the new text had landed. Sevenmodel_performanceresolutions shipped that way (model_performance-uma; specw3-uma-work-tracker-reopen/SPEC.md).Measured at the parent commit (
1621771), with this change absent —docs/lanes/f5c-work-tracker-reopen/evidence/before-defect-demonstration.log:What changed
resolvecompares the resolution TEXT, at BOTH places it decides "did my write land?" — the normal post-write readback and the contended path where_runexhausted its serialization-retry budget. Patching only the first leaves the second silently wrong, and the contended path is exactly where a caller is least able to reason about what happened.On a target that is already resolved, decided before any write so "NOTHING WAS WRITTEN" is literally true:
"idempotent": true, no write attemptedBeadsError, non-zero, nothing written, both texts side by side,reopennamed as a runnable remedyThe identical-text carve-out is required, not a softening: the shipped contention contract sells resolve's no-op as retry safety, and a retry re-sends the identical string. A blanket error would fail a legitimate retry of a write that did land.
Beads.reopen+ CLIreopen+work_reopenis that remedy. It archives the verbatim previous resolution and previousclosed_atinto the item's attributed comment history before transitioning, refuses anything that is notresolved(deliberately not idempotent), and refuses an empty reason.Six new
doctorassumptions fence all of it —doctornow reports 33/33 against the live bd:reopen.reopens,reopen.clears_closed_at,reopen.close_reason_disposition,reopen.emits_event,resolve.divergent_text_refused,resolve.identical_text_idempotent.Two undocumented bd behaviours, measured here
1.
bd reopenCLEARSclose_reason. The spec flagged this as unknown and told the implementer not to build a guarantee on it. Measured: the previous resolution text is gone from the issue row. That makes the archive-first ordering load-bearing — had the wrapper trusted bd to keep it, every correction would have destroyed the record it was correcting. Pinned in a test and inreopen.close_reason_dispositionso a future bd change is loud.2.
bd reopenLEAVES THE OLD ASSIGNEE. Not in the spec; found because this lane's own tool test failed on its claim leg:An item nobody can take is not a correctable item.
reopennow clears the assignee (the sameupdate --assignee ""releasealready does) and verifies by readback.reopen.reopensperforms a directed claim by a different actor — the weakerclaim_nextform passed even with the stale assignee in place.Bonus: the
reopenedaudit trail is two event rows, not one — thereopenedrow carriesevent_type+actor(itscommentcolumn is empty), and--reasonlands as an adjacent attributedcommentedrow. Both are asserted.The open question, answered
A session holds at most one item — enforced at the tool seam (
_Held, andclaim's "already holding" refusal), not by bd. Sowork_reopen(claim=True)from a session that already holds work degrades exactly as the spec required:claimed: false+claim_error, the existing hold untouched, the reopen still standing. Implemented by reusingsession.claim()wholesale rather than re-implementing the claim leg — a second implementation of that path is how a double-claim hole gets reopened.The known cost, surfaced not hidden
reopenclearsclosed_at, so a corrected item re-lands on the correction date and every throughput roll-up moves by one item per correction. The payload says so (closed_at_cleared,previous_closed_at). That cost is precisely why the verb stays explicit instead of being folded intoresolveas an invisible side effect of a call the author believes is idempotent.Tests
make test-unitmake test-integrationmake test-climake test-ledgermake test(all four)modules/tool-work-tracker/testsmake check(ruff + pyright)doctor --quickFail-before evidence (raw output committed under
docs/lanes/f5c-work-tracker-reopen/evidence/)The new tests were run against parent commit
1621771in a clean worktree with this change absent:fail-before-unit.log— 22 failed, 1 passed. (The one pass istest_resolve_returns_an_item_for_every_existing_caller— correctly, sinceresolve()returning anItemis pre-existing behaviour this change preserves.)fail-before-integration.log— 8 failedfail-before-cli.log— 5 failedbefore-defect-demonstration.log— the silent-discard reproduction quoted at the topThe two pre-existing failures, both reproduced at the parent commit
tests/cli/test_cli_surface.py::test_doctor_quick_succeeds_against_the_real_installed_bd— the suite repointsAMPLIFIER_WORK_TRACKER_ROOTat an isolated tmp root with no sweep heartbeat, sosweeps.alivefails anddoctorexits 1. Every other assumption in that run passes, including all six added here. Evidence:parent-commit-doctor-cli-preexisting-failure.log. Filed asmodel_performance-jyg.modules/.../test_reap_recovery.py::test_explicit_resolve_refusal_after_reap_...— a post-reclaimwork_resolveis not refused. This is ledger row CCV1-009 (VIOLATION,work_item_pipeline-dn4) observed behaviourally. Evidence:parent-commit-modules-reap-preexisting-failure.log. Filed asmodel_performance-c0e.Ledger reconciliation
Three probes moved; no disposition changed, and
contracts/+docs/VISION.mdare untouched so CCV1-000's SYNC hashes still hold. CCV1-009 (probe re-pointed to follow the code, claim re-verified as still-VIOLATION), CCV1-015 (count 3 → 4:reopenadopted verify-on-conflict — movement toward the clause, recorded not absorbed), CCV1-023 (single-hold now asserted incidentally in the unrun modules suite; still GAP against "executable viamake test"). Reasons recorded in each row'snotes.Deliberately not in this change
defer()/block()refusing a resolved item (model_performance-2nx) — a separate destructive path; still a real hole.reopen_counton read surfaces (spec §2.8's "additionally").resolve's fence to require custody — explicitly out of scope in the spec.Full lane note, including deviations and spend ($0):
docs/lanes/f5c-work-tracker-reopen/DONE-NOTE.md.Unblocks
model_performance-69y(the 7-item correction backlog).