fix: defer/block refuse a RESOLVED item instead of blanking its resolution - #72
Conversation
Manager: lane died markerless; work RECOVERED and pushed. Stays draft — now CONFLICTING.The lane process ended at ~00:40 mid "commit and push". Its work was complete and uncommitted in its worktree. I committed it unchanged (
Why it is not merged yet
This is not a mechanical conflict I am willing to resolve in passing. #68's post-reclaim fence and write read-back are status-transition logic in the same region as this PR's Status: verified-good, published, conflict-blocked. Top merge candidate once the adapter.py conflict is reconciled against #68/#71. Merge policy note: this is a correctness fix with a reproduced fail-before, so it is merge-eligible under wins-only — the conflict is the only thing standing between it and main. |
c84cb86 to
5a14cec
Compare
Manager verification — rebased onto current main, conflict resolved keeping BOTH behavioursThis branch was The conflict, and why it needed a human decision rather than
|
| tier | result |
|---|---|
| unit | 790 passed |
| integration | 366 passed, 3 skipped (18:14) |
| cli | 82 passed, 1 failed ← pre-existing |
| modules | 115 passed |
| ledger | 26 passed |
ruff check / ruff format --check |
clean, 150 files |
doctor |
All 36 assumptions hold |
doctor was measured, not computed — 36 read off the tool, and AGENTS.md's two claims say 36/36, so they agree.
The one cli failure is test_doctor_quick_succeeds_against_the_real_installed_bd (model_performance-jyg). I ran it on clean origin/main with none of this PR present and it fails identically — it dies at the first assertion (returncode == 0; the isolated test root has no sweep heartbeat), nowhere near this change. Worth noting it is not the \berror\b collision (model_performance-wp6) that is currently blocking #70: this PR's two new assumption descriptions contain no bare error token.
No overlap with the live wp6 lane — checked before pushing: #72 does not touch tests/_util.py.
FIX with a reproduced fail-before → merging (squash + --admin, plain squash refused by the base-branch review policy as with #67/#353/#58/#49).
…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
…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.
5a14cec to
e93dbf4
Compare
|
Re-based a second time onto The second rebase was clean, no conflict, and I re-verified rather than assuming: both markers still present in The one unit failure in that re-run is the known port-binding flake ( |
What this closes
deferandblockon an already-resolved item exited 0, moved it out ofresolved, andblanked its stored
resolution— destroying the official, already-published record with nowarning, no confirmation, no archive and no trace of what it used to say.
Measured first-hand before touching any code (bd 1.1.2, 2026-09-03, throwaway project created and
destroyed through the sanctioned CLI —
evidence/measurement-BEFORE.txt):Two consequences, both load-bearing:
premise of
model_performance-umaandmodel_performance-44f. It was always writable, with nobdinvocation anywhere. 44f's own §1.7 summary table listswork_defer/work_blockas"status/location only — no" against
resolution; that row is wrong on both counts. Neitherlane ever tested these two verbs against a closed item.
reopenverb +work_reopentool;resolveon a closed item fails loud #67 closed. That one discarded the text you send;this discards the text already stored. And it sat one verb away from
release(), whosedocstring goes to deliberate lengths to make exactly this transition "structurally impossible
from this path".
The same measurement after the fix
evidence/measurement-AFTER.txt— the loop stops at its first verb:closed_atis byte-identical before and after the whole attempted loop — the record was notmerely restored, it was never touched.
The change
src/amplifier_work_tracker/adapter.py_status_change_on_resolved_error(),_STATUS_CHANGE_VERB, and a pre-write guard in_set_status_with_reason— the one path both verbs go through. Docstrings on both verbs.src/amplifier_work_tracker/contract.pydefer.refuses_resolved,block.refuses_resolved(shared_refuses_resolvedbody), registered inCHECKS.AGENTS.mddoctoroutput — not34+2arithmetic.tests/integration/test_defer_block.pytests/cli/test_cli_new_verbs.pymodules/tool-work-tracker/tests/test_work_defer_block.pysuccess=Falseon the agent-facing surface.Placed in
_set_status_with_reasonon purpose — the single shared implementation of bothverbs, so there is exactly one guard and it cannot drift between them. The doctor assumptions are
nevertheless asserted separately per verb, so a future change that gives
blockits own pathcannot leave one door open while the other check keeps passing.
Checked BEFORE any write — that ordering is what makes the refusal's own "NOTHING WAS
WRITTEN" literally true, and it is the same ordering
resolveandreleasealready depend on.Deliberately tolerant of a read failure, mirroring
resolve's own pre-write read: an item thatdoes not exist keeps surfacing through bd's own
updatefailure exactly as before. This guard mustnot newly re-diagnose "not found".
Both doors, deliberately in one file
The unsafe door closes; the safe one stays open.
test_reopen_still_succeeds_on_the_same_item_and_still_archives_firstreopens the very itemdeferwas just refused on, and assertsreopenstill archives the previous resolution andclosed_atinto an attributed comment first. A guard that shut both would be a regression ofits own.
And the ordinary workflow is untouched: 6 parametrised tests cover defer/block on open, held
and already-deferred-or-blocked items. Those 6 pass at the parent commit too, which is the
point — a guard that refused too much would break the verb for its actual purpose.
Fail-before evidence
evidence/fail-before-parent-2468a69.txt. The new test files are copied verbatim onto a worktree ofparent
2468a69; onlysrc/is the parent's, pinned withPYTHONPATH=<parent-worktree>/srcand verified in the capture itself. Without that pin the editable install silently resolves the
FIXED source and everything passes.
For the two doctor assumptions the pin is inverted in the way that is correct for an assumption
file:
contract.py(the test) is the lane's,adapter.py(the code under test) is theparent's, unmodified.
Test tiers, by name
The false immutability claim, corrected where it was made
model_performance-uma— already corrected by its own lane before this one started(resolution §(0) and design ADDENDUM 2 both name the
block → clear → claim → resolvepath).model_performance-44f— corrected here,2026-09-03T07:56Z, via the sanctionededitverb on the installed CLI (
--actor agent-2nx-lane), and verified by reading the recordback: a
designaddendum stating the §1.7 row is wrong, what was measured, and that the dooris now closed, with the prior text preserved verbatim beneath it.
ai-notesFINDINGS.md §1.7 lives in a different repo and another lane's directory. Theexact correction is prepared as a ready-to-apply patch at
docs/lanes/2nx-defer-block-refuse-resolved/proposed-44f-findings-correction.md— one paste forwhoever owns that repo.
Spend
$0.00 (authorized $0). No API spend, no DTU, no infrastructure created, so nothing was
registered in the infra ledger and nothing needed tearing down.
sweepwas never run.Refs:
model_performance-2nx. Full note:docs/lanes/2nx-defer-block-refuse-resolved/DONE-NOTE.md.