R2 hold-model correctness fixes (PR #56 review: #2,3,4,5,8,11)#60
Merged
CST-100 merged 7 commits intoJul 5, 2026
Merged
Conversation
holding_readout.exec_href built /executions/{id}?tab=operations&op=N; the
operations tab is dissolved and the document route ignores tab. Emit
/executions/{id}?op={order} and have execdoc.js read ?op= on load, calling
the existing jumpToStep (which expands the collapsed OP card).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kets check_instance_completion inferred close-out from hold_state.wo_blocked alone, and OP auto-complete consulted only blockers_for_complete. Two reachable states closed a WO COMPLETED over an open, undispositioned, containment-bearing NC: a 'resolve by' boundary bound to an OP/terminal row (start_blocked, never consulted) and an op/step hold set onto an already-terminal row nothing revisits. Fix: the final close-out gate now blocks on blocking_issues_for_instance (any non-advisory undispositioned open issue on the WO), and the OP auto-complete gate folds blockers_for_start alongside blockers_for_complete. Dispositioned/advisory issues stay excluded, so legitimate completion is never deadlocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
core/holds filters holds on Issue.procedure_instance_id, but the create and bind seams never validated it. A non-advisory issue could carry a procedure_instance_id that named a different WO than its anchor step (blocks the wrong WO), or an MCP bind could point an issue at a foreign execution's step (reports success, blocks nothing). - issues.create / MCP _raise_issue: for non-advisory containment, derive procedure_instance_id from the step anchor when unset and reject an anchor (or a second anchor) that names a different WO. A bare draft (no anchor, no WO) still allowed — the WO is linked later. - MCP _bind_issue_hold: adopt the target WO when the issue names none, reject when it names a different one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_containment (API + MCP) applied containment_step_id only when non-null, so a mis-bound 'resolve by' boundary was immutable, and it never checked the posted step belonged to the issue's work order. Decision (flagged for confirmation): use explicit-presence semantics via model_fields_set / key-in-args (a documented explicit-null clear), rather than the disposition_type empty-string sentinel — containment_step_id is an int field, and an explicit JSON null is the natural clear. An omitted field leaves the boundary untouched; an explicit null clears it. A non-null boundary must belong to the issue's WO (seeds the WO on an unlinked issue, rejected when it names a different one). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
skip_step checked only containment/scope holds, so after a disposition was
signed COMPLETE still refused ('Waiting on redline op') while SKIP marked the
host step terminal SKIPPED — stranding authorized rework, reversing the
invariant the old code asserted by name.
Decision (flagged for confirmation): the safe reading is that an open
redline-rework op blocks SKIP too. Added core.skip_blockers = held scope +
the redline-class blockers only, deliberately splitting out strict_sequence
and OP-dependency ordering (skipping legitimately need not wait on
predecessors). skip_step now gates on it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_step_row.html and _dockbar.html derived 'held' from step_holding_ncs (raised/containment) only, ignoring bound 'resolve by' holds (start_blocked) and sequence/redline gates — both of which the server refuses. A held or sequence-gated row rendered an active COMPLETE the server then 400'd. Fix at altitude: the route computes one authoritative answer per step execution — complete_gate_by_se (core.complete_blockers: held scope + sequence) and skip_gate_by_se (core.skip_blockers: held scope + redline). A new ex.complete_control macro renders the button or the absent-control blocker line from that single field; both templates use it, and SKIP hides on skip_gate. Templates no longer re-derive gating. Removes the dead step_complete_blockers/step_start_blockers context keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per 'one fact, one home', consolidate duplication adjacent to these fixes: - execution_flow._status_value was a copy of holds._val; alias the canonical core/holds helper instead. - routes.py inlined the terminal-status set twice; reuse execution_flow.TERMINAL_STEP_STATUSES. - drop the dead context['users'] query (execution templates read the identical active_users). The dead step_complete_blockers/step_start_blockers keys were already removed with F4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes a cluster of interrelated correctness bugs in the quality-hold / work-order-completion model found in the PR #56 review (findings #2, #3, #4, #5, #8, #11). Branch is off the current tip of
features/issues-exec-parts-r2(contains the already-committed mechanical XSS/500/link fixes at 7b8b232); this PR targets that branch, not devel/master. One commit per finding, plus a cleanup commit.Full suite: 788 passed, 1 skipped.
ruff check src/clean.F2 — WO could close COMPLETED over an open, undispositioned NC
Wrong:
check_instance_completioninferred close-out fromhold_state.wo_blockedalone, and OP auto-complete consulted onlyblockers_for_complete. Two reachable states closed a WO over an open containment-bearing NC: (a) a "resolve by" boundary bound to an OP/terminal row (lands instart_blocked, never consulted); (b) an op/step hold set onto an already-terminal row nothing revisits.Fix: the final close-out gate is now authoritative on the issue set —
blocking_issues_for_instance(any non-advisory, undispositioned, open issue on the WO). The OP auto-complete gate foldsblockers_for_startalongsideblockers_for_complete. Dispositioned/advisory issues stay excluded, so legitimate completion is never deadlocked.Tests:
test_wo_stays_open_under_boundary_bound_to_terminal_step,test_wo_stays_open_under_op_hold_on_terminal_op,test_op_does_not_auto_complete_over_bound_hold_on_op_row(all verified failing on the old gate).F3 — a hold bound to the wrong/no work order reported success but blocked nothing
Wrong:
core/holdsfilters onIssue.procedure_instance_id, but the create/bind seams never validated it — an issue could name a different WO than its anchor step, or an MCP bind could point at a foreign execution's step.Fix:
issues.create/ MCP_raise_issuederiveprocedure_instance_idfrom a step anchor when unset and reject an anchor that names a different WO (bare drafts with no anchor + no WO still allowed — the WO is linked later). MCP_bind_issue_holdadopts the target WO when the issue names none, rejects a different one.Tests:
test_create_issue_rejects_anchor_in_different_wo,test_create_issue_derives_wo_from_anchor, MCPtest_bind_issue_hold_rejects_foreign_work_order,test_bind_issue_hold_adopts_work_order_when_unset.F4 — held/sequence-gated rows rendered an active COMPLETE the server then 400'd
Wrong:
_step_row.htmland_dockbar.htmlderived "held" fromstep_holding_ncs(raised/containment) only, ignoring bound "resolve by" holds and sequence/redline gates.Fix at altitude: the route computes one authoritative answer per step execution —
complete_gate_by_se(core.complete_blockers: held scope + sequence) andskip_gate_by_se(core.skip_blockers: held scope + redline). A newex.complete_controlmacro renders the button or the absent-control blocker line from that single field; both templates use it, and SKIP hides onskip_gate. Templates no longer re-derive gating. Removed the deadstep_complete_blockers/step_start_blockerscontext keys.Test:
test_bound_hold_row_renders_no_active_complete(+ existingtest_held_step_renders_blocker_line_not_complete_button/test_signed_disposition_restores_controlsstill green).F5 — SKIP bypassed the redline-rework gate ⚑ decision flagged
Wrong:
skip_stepchecked only containment/scope holds, so after a disposition was signed COMPLETE still refused ("Waiting on redline op") while SKIP marked the host step terminal SKIPPED — stranding authorized rework.Fix: added
core.skip_blockers= held scope + the redline-class blockers only, deliberately splitting outstrict_sequence/OP-dependency ordering (skipping legitimately need not wait on predecessors).skip_stepgates on it.⚑ Decision for confirmation: I implemented the safe reading — an open redline-rework op blocks SKIP. If product intent is that SKIP should be allowed to abandon a host step and its rework, revert this gate.
Tests:
test_skip_blocked_by_open_redline_after_disposition,test_skip_not_blocked_by_strict_sequence_predecessor.F8 — containment_step_id couldn't be cleared; foreign step ids weren't validated ⚑ decision flagged
Wrong:
set_containment(API + MCP) appliedcontainment_step_idonly when non-null (a mis-bound boundary was immutable) and never checked the posted step belonged to the issue's WO.Fix: explicit-presence semantics (
model_fields_set/ key-in-args) — an omitted field leaves the boundary untouched, an explicit null clears it. A non-null boundary must belong to the issue's WO (seeds the WO on an unlinked issue, rejected when it names a different one).⚑ Decision for confirmation (clear semantics): I chose a documented explicit-null clear over the disposition_type empty-string sentinel, because
containment_step_idis an int field and an explicit JSONnullis the natural clear signal. devel's explicit-null rejection was for non-nullable PATCH fields; this field is nullable, so a clear is appropriate.Tests:
test_set_containment_clears_boundary_with_explicit_null,test_set_containment_rejects_foreign_step(+ MCP mirror).F11 — HOLDING link pointed at the dissolved operations tab
Wrong:
holding_readout.exec_hrefbuilt/executions/{id}?tab=operations&op=N; the operations tab is gone and the document route ignorestab.Fix: emit
/executions/{id}?op={order};execdoc.js initExecDocreads?op=on load and calls the existingjumpToStep(order)(expands the collapsed OP card).Test:
test_holding_readout_links_to_document_op_param.Cleanup (one fact, one home)
execution_flow._status_valuewas a copy ofholds._val→ alias the canonicalcore/holdshelper.execution_flow.TERMINAL_STEP_STATUSES.context['users']query (execution templates read the identicalactive_users).🤖 Generated with Claude Code