Skip to content

item 6d: Control grows a resume path, and the gateway holds elicitations - #11

Merged
arpanghoshal merged 2 commits into
mainfrom
item-6d-resume
Sep 3, 2026
Merged

arpanghoshal merged 2 commits into
mainfrom
item-6d-resume

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

Your §6.9 decision, implemented. SPEC-v0.2 §6.9 amended, §11 and ARCHITECTURE §6 with it. Item 6 is now complete — T26 and T30b land here.

Suspension is modelled the way v0.1 models "nothing happened"

An explicit opt-in an executor raises, never a default and never inferred.

Suspended(continuation) is handled in Control.execute before the generic branch, alongside NotExecuted. The record stays EXECUTING, the lease is extended by Control(suspend_timeout=…), the continuation is held via hold_continuation in the same transaction, EXECUTION_SUSPENDED is appended, no receipt is written — the same rule as an action awaiting a human — and the signal propagates so the gateway can relay the elicitation.

With no effect key it holds nothing, and the event records held: false. Suspension without an effect key gives no protection at all — a concurrent caller can start the same work — exactly as retries without one give none. Logged, not silently pretended.

Control.resume(continuation, executor) consumes the continuation atomically, rehydrates the action from the store (which is what lets a process that restarted mid-round still finish one), checks the record is still EXECUTING under a live lease, appends EXECUTION_RESUMED, and runs the executor through the same outcome mapping execute uses. That path is now one private function called by both. A resumption that decided outcomes differently would be a second implementation of the only question this library exists to answer.

resume re-evaluates the policy for the receipt, not to re-decide: the action was decided and reserved on the first leg, and refusing at resume would strand a reservation the remote may already be acting on. Marked in the spec.

Control remains the only module that composes the others. ARCHITECTURE §6 now says so about the gateway explicitly, and says why: a gateway that owned the reservation would be a second implementation of v0.1 §5.5's asymmetry, the one rule here that must not drift.

The gateway half

input_required → Suspended(requestState); a follow-up carrying requestState → Control.resume; an elicitation with no requestState → §6.9.3's floor (AMBIGUOUS, and the continuation refused with -41005). Both of §6.9.2's bounds enforced: the lease via suspend_timeout, the rounds via continuation_rounds.

A concurrent duplicate during an outstanding elicitation is refused with -41004 in_progress — the guarantee that actually matters across a round trip.

Mutation table — 30 mutations, store half and gateway half together

# Group Result
M1–M8 the signal: handled before the generic branch, no outcome, lease, held:false, bytes, non-empty red
M9–M12 resume: same mapping, same attempt, EXECUTION_RESUMED, consumed before the executor runs red
M13–M21 the store: one resumption per suspension, same-transaction consume, live-record checks, rehydration, uniqueness, rounds red
M22–M28 the gateway: Suspended, §6.9.3's floor, resume routing, -41009, the round bound, the meta red
M29–M30 T30b: id: stripped, and only id: red

Three started green. Two were harness bugs of kinds already seen — the ambiguous-anchor problem (except sqlite3.IntegrityError appears in put_approval_request too, and replace(…, 1) hit that one), and an equivalent mutant: hmac.compare_digest vs == differ in timing, not correctness, so no behavioural test can separate them. M21 now mutates the matching itself.

The third was real, and it is the same defect this codebase keeps producing: a resolved record and a lapsed lease both refused a resumption with the same exception and wrote nothing, so removing either guard changed nothing visible. One branch now, naming the state it found.

That's items 2, 5, 6a, 6b and 6d. I'd write it into CLAUDE.md as its own rule when you next touch it — the existing "defence-in-depth hides mutations" bullet describes the symptom, but the specific form here is narrower and mechanical: a guard that can only fire when a later guard would also fire, with the same observable result, is documentation — and a test asserting only status/type/code cannot tell you which one you wrote.

Verification

1162 tests pass (1098 → 1162), mypy --strict src/, ruff check and ruff format --check clean.

Every §10 acceptance test now exists except T27/T28 (item 7), T29 and T30's named test (item 8).

SPEC-v0.2 §6.9 amended, §11 and ARCHITECTURE §6 with it. A held reservation
spans two HTTP requests and therefore two Control calls, and Control.execute
writes a terminal outcome for any executor exception — so the gateway could
not express "suspend, record nothing" through it. The kernel grows one signal
and one method rather than the gateway growing a second reservation path.

Suspension is modelled the way v0.1 models "nothing happened": an explicit
opt-in an executor raises, never a default and never inferred. Suspended is
handled above the generic branch, alongside NotExecuted. The record stays
EXECUTING, the lease is extended, the continuation is held in the same
transaction, EXECUTION_SUSPENDED is appended, no receipt is written — the same
rule as an action awaiting a human — and the signal propagates so the gateway
can relay the elicitation.

Suspended with no effect key holds nothing and the event says so. Suspension
without an effect key gives no protection at all, exactly as retries without
one give none; it is logged rather than silently pretended.

Control.resume consumes the continuation atomically, rehydrates the action
from the store — which is what lets a process that restarted mid-round still
finish one — and runs the executor through the SAME outcome mapping execute
uses. That path is now one private function called by both. A resumption that
decided outcomes differently would be a second implementation of the only
question this library exists to answer.

resume re-evaluates the policy for the receipt, not to re-decide: the action
was decided and reserved on the first leg, and refusing at resume would strand
a reservation the remote may already be acting on.

The gateway maps input_required to Suspended(requestState), a follow-up
carrying requestState to Control.resume, and an elicitation with no
requestState to §6.9.3's floor. Both of §6.9.2's bounds are enforced: the
lease via suspend_timeout, the rounds via continuation_rounds.

Control remains the only module that composes the others; the gateway calls
it. ARCHITECTURE §6 says so explicitly now.
Mutation-testing §6.9 found the same pattern this codebase keeps producing: a
resolved record and a lapsed lease both refused a resumption with the same
exception and wrote nothing, so removing either changed nothing a caller or a
test could see. One branch now, naming the state it found; the tests
distinguish the two by message.

The harness had the ambiguous-anchor bug again — `except sqlite3.IntegrityError`
appears in put_approval_request too, and replace(..., 1) hit that one. And
compare_digest vs == is an equivalent mutant for correctness, so M21 now
mutates the matching itself rather than the comparison primitive.
@arpanghoshal
arpanghoshal merged commit dfe23e3 into main Sep 3, 2026
3 checks passed
@arpanghoshal
arpanghoshal deleted the item-6d-resume branch September 3, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant