Skip to content

item 6d (partial): the continuation store, and resumption stripping - #10

Merged
arpanghoshal merged 1 commit into
mainfrom
item-6d-elicitation
Sep 3, 2026
Merged

arpanghoshal merged 1 commit into
mainfrom
item-6d-elicitation

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

Last stage of item 6 — and it is deliberately incomplete. §6.9's gateway-side held reservations are not here. What is here is built, tested and green; what is missing needs a decision from you, set out below.

What ships

hold_continuation / take_continuation (§6.9.2, §11), in their own continuations table rather than as new columns on effects — §2.2 records that v0.2 has no migration story, and CREATE TABLE IF NOT EXISTS gives an existing database the new table for free where ALTER TABLE would need one.

The requestState is compared with hmac.compare_digest and consumed in the same transaction that admits the continuation, so one elicitation admits exactly one. Two continuations racing on one held key is the duplicate execution this product exists to prevent, wearing an inputResponses field as a disguise. The record checks run first, so an expired or resolved reservation is refused as what it is rather than as an unknown continuation. The held state survives a new store on the same file — a gateway that restarted mid-elicitation would otherwise strand a reservation nobody can continue.

legacy.py and T30b. SSE id: fields are stripped from an intercepted response stream, line by line rather than event by event: the gateway must relay each event as it arrives, so it cannot wait for an event to end before deciding what to forward. Only id: goes; event:, data:, retry: and comments pass through untouched.

EXECUTION_SUSPENDED / EXECUTION_RESUMED join the event set (§11).

What does not, and why

§6.9.2 requires the effect record to stay EXECUTING with no outcome written while the client answers the elicitation. That spans two HTTP requests, and therefore two Control.execute calls.

Control.execute writes a terminal outcome for any executor exception (v0.1 §5.5), and there is no way for an executor to say "suspend, record nothing". So an input_required today falls to §6.8's unrecognized-resultType row and is recorded AMBIGUOUS — which is §6.9.3's fail-closed floor applied to every elicitation rather than only to the ones with no requestState. That is safe and it is not what §6.9.2 asks for.

I built the executor branch for it and reverted it, because closing this needs one of two decisions and both are wider than §6.9's own text:

  1. Control grows a resume path — an explicit way to hand a held reservation back to a second execute. Keeps ARCHITECTURE §6 intact; adds public API §11 does not list.
  2. The gateway owns reserve/begin/commit for intercepted calls — no kernel change, but it makes the gateway the second module that composes the others, against ARCHITECTURE §6's "Control is the only module that composes the others", and duplicates the outcome handling that item 6c was careful not to duplicate.

I'd take (1): the duplication in (2) is exactly the kind that drifts, and the asymmetry in Control.execute is the one piece of logic in this codebase that must have a single implementation. But it is a public-API decision and a spec amendment, so it is yours.

I stopped rather than guess, at the end of a long session, on a change that touches the kernel's composition rule.

Where that leaves v0.2

T26 store half done and tested; gateway half blocked on the decision above
T30b done
Everything else in §6 done in 6a–6c

The gateway is usable today for every tool that does not elicit. A tool that does elicit gets an AMBIGUOUS effect per call — correct, and expensive.

Verification

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

No mutation table for this PR: the store half's mutations belong with the gateway half that consumes them, and running one over code whose consumer does not exist yet would be the false-green problem from item 5 in a new costume.

SPEC-v0.2 §6.9.2's StateStore half and §6.2's resumption rule (T30b). §6.9's
gateway-side held reservations are NOT here, for a structural reason recorded
as a `# SPEC:` note in server.py and set out in the PR.

hold_continuation / take_continuation live in their own `continuations` table
rather than as new columns on `effects`: §2.2 records that v0.2 has no
migration story, and CREATE TABLE IF NOT EXISTS gives an existing database the
new table for free where ALTER TABLE would need one.

The request_state is compared with hmac.compare_digest and consumed in the
same transaction that admits the continuation, so one elicitation admits
exactly one. Two continuations racing on one held key is the duplicate
execution this product exists to prevent, wearing an inputResponses field as a
disguise. The record checks come first, so an expired or resolved reservation
is refused as what it is rather than as an unknown continuation.

The held state survives a new store on the same file, which is the point: a
gateway that restarted mid-elicitation would otherwise strand a reservation
nobody can continue.

legacy.py strips SSE id: fields from an intercepted response stream, line by
line rather than event by event — the gateway must relay each event as it
arrives, so it cannot wait for an event to end before deciding what to
forward. Only id: is removed; event:, data:, retry: and comments pass through.

EXECUTION_SUSPENDED and EXECUTION_RESUMED join the event set (§11), named for
what happens to the attempt rather than for the transport that caused it.
@arpanghoshal
arpanghoshal merged commit e4ee285 into main Sep 3, 2026
3 checks passed
@arpanghoshal
arpanghoshal deleted the item-6d-elicitation branch September 3, 2026 19:12
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