Validation report — S4-01 (HITL interrupt and resume)¶
Date: 2026-05-26
Validator: phase-story-validator (inline four-lens analysis — Coverage, Test-Quality, Consistency, Design-Patterns — applied directly after Stage 1's Context Brief. Mirrors the in-phase precedent set by the S1-01, S1-02, S2-01, S2-02, S3-01, and S3-02 validations: the pre-validation file was a 17-line stub, the four lenses converged sharply, and spawning four parallel critic agents would have burned tokens without changing the verdict.)
Verdict: HARDENED
Story path: docs/phases/06-sherpa-vuln-loop/stories/S4-01-hitl-interrupt-and-resume.md
Why HARDENED (not STRONG, not RESCUE)¶
The story's architectural intent matches the design — final-design.md §"Decisions of record" item 5 ("Typed interruption. HITL is a discriminated-union outcome carrying reason, evidence, and resumption contract. 'Paused' is not a boolean side channel.") + phase-arch-design.md §"Scenarios" #3 (retry-exhaustion → pause → approve → resume) + phase-arch-design.md §"Failure modes" row 4 ("stale human resume token | resume validator | reject and remain paused") + High-level-impl.md §"Step 4 — HITL and failure routing" triple-pin the substrate concerns. But the pre-validation 17-line story file left every load-bearing decision implicit. Specifically:
- Goal vague. "Emit typed HITL interrupts and validate resume input" doesn't name the artifact (module path), the type universe (which discriminated union? which variant classes?), the resume-input fields, or the integrity-policy gate. The executor would invent shapes incompatible with the rest of Phase-6.
- Zero AC checkboxes (three dash bullets). No individually-verifiable assertions; the Validator pass downstream could not binary-pass-fail. Mirrors the exact failure pattern S1-02, S2-01, S2-02, S3-01, S3-02 all surfaced in pre-validation.
- "Retry exhaustion" undefined. No definition of when retry exhausts —
attempt_number >= MAX_RETRIES(the S3-01-pinned exact value)?GateFailedRetryablecount? A mutantMAX_RETRIES = 1would silently pause on the first gate failure and "pass" the original AC. - "Typed HITL interrupts" un-typed. No payload shape, no variant universe, no
Annotated[..., Field(discriminator="kind")]discipline named. An executor would likely ship a singleHitlInterrupt(reason: str, evidence: dict)anaemic model — directly contradicting final-design.md item 5's "discriminated-union" mandate. - "Stale" undefined. By timestamp? By chain head? By handoff digest? By workflow_id binding? By approver_id replay? The phase-arch-design.md row 4 ("stale human resume token") gives no further guidance. An executor would pick one staleness dimension, miss the others, and silently accept a forged token.
- "Malformed" undefined. Unparseable JSON? Missing required fields? Wrong workflow_id? Token-signature mismatch? Each is a different Pydantic + control-flow path. The mutation-resistance bar is invisible without enumeration.
- No integrity-policy precedence. Resume input validation must run AFTER
hydrate_or_failsucceeds — otherwise a tampered chain plus a legitimate approval token would be silently accepted. The story didn't name this ordering; an executor would either invent a parallel chain-recomputation (duplicating S2-02 — anti-pattern, drift hazard) or skip the integrity check entirely (silent-tamper-acceptance hazard). - "Latest verified checkpoint" un-anchored. The phrase appears in
phase-arch-design.md §"Scenarios" #3verbatim but the resume gate must consume S2-02'sHydrated.latest_state_kind— the story didn't say so. An executor might re-querystore.tail_chain_head()directly (substrate-only, no integrity check — silent regression). - No "remains paused" structural enforcement.
phase-arch-design.md §"Failure modes"row 4 says "reject AND remain paused." Without an AST fence ensuring the reject arms don't append aTransitionEvent, an executor could "helpfully" emit a(awaiting_human_review, failed_unrecoverable)row on the stale-approval arm — silently advancing the workflow into a terminal state where the human reviewer can no longer retry. - No interaction with S3-01 placeholder. S3-01's hardened story explicitly defers the typed payload + resume validator to S4-01 and emits a placeholder
Escalate(reason="awaiting_human_review"). ButEscalationReason(intransforms/outcomes.py) does NOT currently include"awaiting_human_review"— the placeholder is technically illegal at the closed-Literal level. The pre-validation story did not address the boundary handoff at all. - No
HumanReviewReasoncross-table consistency. The S1-02AwaitingHumanReview.review_reason: HumanReviewReasonLiteral is the closed four-value universe. Without a cross-table assertion that everyHitlInterruptvariant maps to exactly oneHumanReviewReasonvalue (and vice versa), a future fifthHumanReviewReasonvalue would land silently with no matching interrupt payload — a drift hazard that compounds across Phase-7'smigrationHITL surface. - No idempotency / replay-protection discipline. Replaying the same approval token must be a no-op (or rejected); the original story didn't address this. An executor might detect this only via runtime test flakiness ("works once, fails on the second test run because the chain advanced").
- No
__all__/ contract-snapshot extension. Every Phase-6 story (S1-01, S1-02, S2-01, S2-02, S3-01, S3-02) extends the public-surface allowlist + the contract-snapshot meta-test additively. The pre-validation file said nothing. - No
mypy --strictAC. Standard closeout gate; the discriminator union +match-with-assert_neverare the load-bearing strictness checks. - No anti-refactor block. The "make it pluggable" reflex was unguarded: an executor under deadline pressure could ship a
HitlInterruptRegistry/@register_hitl_variantdecorator +BaseHitlInterruptABC +ResumeStrategyProtocol — every one a premature abstraction the Phase-6 architectural decisions explicitly reject (ADR-0001 stable contract + final-design.md item 5 closed-union mandate + Rule 2 "Simplicity first"). - No mutation-resistance pass. Every original AC was satisfiable by a trivial implementation: "Retry exhaustion enters
AwaitingHumanReview" passes with a mutant that always entersAwaitingHumanReviewregardless of retry state; "Resume rejects stale or malformed approval payloads" passes with a mutant that always rejects (the "approved resume" AC catches one direction but not the other); "Approved resume continues from latest verified checkpoint" passes with a mutant that ignores the resume input entirely and just callshydrate_or_fail.
All in-place fixable, none requires re-running phase-story-writer. The story's structure (one-sentence goal, three vague ACs, three-line TDD plan) survives in shape — three ACs grew to 14 numbered checkbox ACs across five labeled sub-sections, the TDD plan was reordered with the 12-item anti-refactor block, References / Files-to-touch / Out-of-scope / Notes-for-implementer were added. Verdict: HARDENED.
Context Brief (Stage 1)¶
Story snapshot¶
- Goal (pre-validation): "Emit typed HITL interrupts and validate resume input before continuation." Vague — no module path, no type universe, no integrity-policy ordering, no replay protection.
- Goal (post-validation): ship
src/codegenie/workflows/{_hitl,hitl}.pycarrying (i) the closed four-variantHitlInterruptdiscriminated union (one variant perHumanReviewReasonLiteral value), (ii) theResumeInputPydantic model +ApprovalTokennewtype +mint_approval_tokensmart constructor, (iii) the closed five-variantResumeVerdictsum type +_dispatch_resume_verdictwith exhaustivematch, (iv) theresume_or_rejectintegrity-policy gate (which runshydrate_or_failFIRST then layers approval-policy onHydrated), (v) theemit_interruptboundary-write function replacing S3-01'sEscalate(reason="awaiting_human_review")placeholder, (vi) the AC-8 "remains paused on reject" AST fence + meta-mutation guard, and (vii) the parity matrix + contract-snapshot +__all__closeout. This story is the fourth concrete consumer of the typed-substrate-and-sole-site-policy-gate pattern in Phase-6 (S1-02 ledger + S2-01 store + S2-02 verifier + this). - Status pre-validation:
Ready— never executed; never validated. - Status post-validation:
HARDENED.
Authoritative sources¶
- final-design.md §"Decisions of record" item 5 verbatim (typed interruption with reason + evidence + resumption contract; "'Paused' is not a boolean side channel" — drives AC-1 + AC-3 + AC-4 closed-union shape); §"Main workflow" step 6 ("repeated failure or policy block →
AwaitingHumanReview" — drives AC-9 retry-exhaustion emission); §"State model" (AwaitingHumanReviewis class-level terminal but operationally resumable — drives AC-7 (iii) dual-nature invariant). - phase-arch-design.md §"Scenarios" #3 verbatim ("Gate fails twice, graph emits
AwaitingHumanReview, process exits cleanly, resume input is validated, approved transition continues from the latest verified checkpoint." — AC-9 + AC-10 integration scenario); §"Failure modes" row 4 verbatim ("stale human resume token | resume validator | reject and remain paused" — AC-3 + AC-4 + AC-8 fence + AC-10 negative scenarios); §"Process view" (G->>L: checkpoint terminal / retry / interruptis AC-9's emission boundary;G->>L: verify + hydrateon resume is AC-5 / AC-6's integrity-policy precedence). - ADR-0001 §Decision —
VulnRemediationResult.terminal_statecarriesawaiting_human_review; AC-12 + AC-13 keep the public surface byte-equal. - ADR-0003 §Decision ("verify the previous chain head before hydration on resume" — drives AC-6 integrity-policy precedence) + §Consequences ("Failed verification transitions to
FailedUnrecoverable" — the integrity-failure path on resume; this story re-useshydrate_or_fail, does NOT add a parallel site). - High-level-impl.md §"Step 4 — HITL and failure routing" verbatim ("Add typed interrupt payloads and resume validation. Distinguish retryable, terminal, and failed-unrecoverable states. Prove stale approvals are rejected.").
- S2-02 hardened story — the SOLE-site integrity-policy gate (
hydrate_or_fail) precedent + the AST fence (test_hydrate_no_state_construction.py) precedent + the parity matrix + the contract-snapshot extension pattern + the "two surfaces" public-API split (canonical + convenience wrapper). This story mirrors S2-02 line-by-line at the structural level. - S3-01 hardened story — currently emits
Escalate(reason="awaiting_human_review")placeholder; this story replaces the placeholder viaemit_interrupt(...). TheEscalationReasonLiteral intransforms/outcomes.pydoes NOT currently include the value — surfaced in Notes-for-implementer §"WhyEscalationReasonis NOT amended" with explicit grep instructions for the executor. src/codegenie/workflows/vuln_ledger.py—HumanReviewReasonimport;AwaitingHumanReviewconstruction;_LEGAL_TRANSITIONSbyte-equal-unchanged check.src/codegenie/workflows/replay.py—hydrate_or_fail(the AC-5 dependency); theHydrated.latest_state_kindfield (AC-5 step 2 dispatch); the fence attest_hydrate_no_state_construction.py(AC-8 pattern precedent).src/codegenie/transforms/outcomes.py§HumanReviewReason(closed four-value Literal — AC-7 byte-equality target); §HumanReviewReasonvalue set:"no_concrete_match","trust_outcome_failed","policy_violation_unrecoverable","MULTI_PACKAGE_CVE"; §EscalationReason(closed seven-value Literal that does NOT currently include"awaiting_human_review"— Notes-for-implementer addresses the S3-01 placeholder)._attempts/_lessons.md— four cross-story lessons consumed: (a) "two definitions of terminal coexist" — AC-7 (iii); (b) "store types do NOT enter__all__" — AC-12 keepsResumeVerdictvariants private; (c) "detection-substrate-only vs integrity-policy" — AC-6 ordering invariant; (d) "sanitization-aware fold" — AC-9 (ii) round-trip via the S2-02 reconstruction pipeline.
Hardest design tensions resolved¶
Tension 1 — HitlInterruptRegistry vs closed-Annotated[..., Field(discriminator="kind")]. The Phase-6 architectural decision (final-design.md item 5 closed-union mandate + ADR-0001 stable contract + S1-02 sum-type precedent + Anti-refactor #2 of every Phase-6 story so far) makes the closed union the right shape. Rule-of-three for registries of HITL interrupts is unmet (we have one HITL surface — vulnerability-remediation; Phase-7 ships its own HITL surface, not a shared registry). Resolution: closed Annotated[..., Field(discriminator="kind")], fourth concrete consumer of the closed-Pydantic-sum-type pattern (S1-02 ledger + S2-02 verdict + S2-02 hydration result + this). Anti-refactor #1 explicit.
Tension 2 — HitlInterrupt payload coupling to the ledger. Should AwaitingHumanReview grow an evidence: HitlInterrupt field? The S1-02 design left the TransitionEvent.triggering_outcome: JsonValue open generic substrate precisely for this case — domain payloads ride through triggering_outcome, the ledger sum type stays neutral. Coupling AwaitingHumanReview.evidence to HitlInterrupt would force a Phase-7 ledger amendment for migration HITL flavors (a sibling sum type) — the ledger would need an evidence: HitlInterrupt | MigrationHitl union. Resolution: keep the payload at TransitionEvent.triggering_outcome; the ledger sum type is byte-equal-unchanged. Anti-refactor #9 explicit.
Tension 3 — Resume validation as a typed exception vs tagged union. The S2-02 precedent (Anti-refactor #3 "No boolean return from verify() — the tagged union IS the contract") + the project-canonical sum-type discipline + the integrity-policy total-function discipline all point at a closed verdict union. Raising would defeat exhaustiveness narrowing under mypy --strict. Resolution: closed five-variant ResumeVerdict union; the gate is total. Anti-refactor #7 explicit.
Tension 4 — resume_or_reject returns ResumeAccepted | FailedUnrecoverable (two-variant) vs full ResumeVerdict (five-variant). Both surfaces are needed:
- resume_with_full_verdict is the canonical surface — every input maps to one of five verdicts. Testable, exhaustive, mutation-resistant.
- resume_or_reject is the convenience wrapper — folds the four non-accepted verdicts into FailedUnrecoverable(reason="policy_violation", ...) so the orchestrator's outer match on RemediationOutcome doesn't have to grow five new arms.
The two-surface design mirrors S2-02's ReplayVerifier.verify() (canonical) vs hydrate_or_fail() (convenience). Resolution: ship both; document the rationale in Notes-for-implementer §"Why two surfaces."
Tension 5 — Stale approval definition: timestamp window vs chain-head binding vs handoff-digest binding. Each alone is insufficient (Notes-for-implementer §"Why the canonical approval message must include both"). Resolution: bind ALL THREE in the canonical token-message bytes. The token recomputation in the resume validator catches any drift in any of the three dimensions. AC-3 (token-recomputation in model-validator) + AC-5 step (3) (defense-in-depth recomputation at the gate) + AC-5 step (4) (chain-head tail check) + AC-5 step (5) (TTL check) layer the defenses.
Tension 6 — EscalationReason amendment vs additive emit-interrupt call. S3-01's placeholder Escalate(reason="awaiting_human_review") is technically illegal at the EscalationReason Literal level. Amending EscalationReason to include the value would conflate two semantically distinct categories (pre-subgraph escalation vs in-subgraph HITL). Resolution: do NOT amend EscalationReason; replace S3-01's placeholder with an additive emit_interrupt(...) call from the boundary node. Anti-refactor #4 + Notes-for-implementer §"Why EscalationReason is NOT amended" explicit, with grep-instructions for the executor to handle all three possible current-state scenarios.
Tension 7 — Phase-6 e2e + property test ownership. S6-01 owns the closeout e2e scenarios (tests/e2e/scenarios.yaml) + the workflow-scope replay-determinism property. S4-01 owns the unit-level + integration-level slice of the HITL + resume substrate. Resolution: explicit Out-of-scope statement deferring the e2e + property tests to S6-01; this story ships the substrate the closeout composes.
Four-lens findings (inline, no parallel subagents)¶
Lens 1 — Coverage¶
| Finding | Severity | Resolution |
|---|---|---|
AC-1 "Retry exhaustion enters AwaitingHumanReview" unverifiable — no retry-exhaustion definition |
block | AC-9 — emit_interrupt(store, workflow_id, interrupt, prior_state_id, ...) with explicit prior_state_id ∈ {"gate_failed_retryable", "patch_applied", "plan_ready"} membership check; AC-10 codifies the integration scenario with MAX_RETRIES = 3 boundary explicit. |
| AC-2 "Resume rejects stale or malformed approval payloads" un-anchored — no shape for what "stale" or "malformed" means | block | AC-3 (seven mutation classes for ResumeInput) + AC-4 (five verdict variants with explicit reason Literals) + AC-5 (eight-step gate with explicit ordering); AC-10 negative scenarios codify the four reject classes verbatim. |
| AC-3 "Approved resume continues from latest verified checkpoint" — no integrity-policy precedence | block | AC-5 step (1) MUST call hydrate_or_fail first; AC-6 three scenarios assert the precedence; AC-8 AST fence structurally enforces. |
| No idempotency / already-resumed coverage | block | AC-5 step (6) detect already-resumed via chain-head re-walk; AC-4 AlreadyResumed verdict; AC-10 negative scenario #4 codifies. |
| No "remains paused" structural guarantee | block | AC-8 AST fence — reject arms must NOT append TransitionEvent; AC-10 negative scenarios assert chain head is unchanged after reject. |
| No closeout (allowlist + snapshot + mypy) | harden | AC-12 (allowlist grows additively) + AC-13 (snapshot extension) + AC-14 (mypy + lint + import-linter). |
No cross-table consistency between HitlInterrupt and HumanReviewReason |
harden | AC-7 (i) forward + (ii) backward consistency Hypothesis property; (iii) dual-nature invariant; (iv) sibling-domain identity NOT asserted (cross-domain disambiguation). |
ApprovalToken newtype absent |
harden | AC-2 — newtype + smart constructor + chokepoint AST fence (mirrors BundleCacheKey precedent). |
| Parity matrix coverage missing | harden | AC-11 — resume_or_reject parametrize in the S2-01 parity contract test. |
| Two-surface API split unspecified | nit | Notes-for-implementer §"Why two surfaces"; AC-5 step (8) explicit; AC-12 lists both functions in __all__. |
Lens 2 — Test Quality¶
| Finding | Severity | Resolution |
|---|---|---|
| Original ACs satisfiable by mutant always-pauses / always-rejects / always-resumes | block | Each AC includes a "Mutation thinking" note naming a specific mutation class the AC catches (wrong retry boundary, structural-matching fallback, parallel chain recomputation, ordering inversion, etc.). |
| No partial-write meta-test on the AST fence (AC-8 (iv)) | block | AC-8 (iv) parity-meta mutation guard: plant a broken hitl.py fixture that appends on reject; assert the fence fires loud. Mirrors S2-02 AC-15 precedent. |
mint_approval_token determinism unspecified |
block | AC-2 — same args → same token (canonical-bytes purity); AST chokepoint enforces single construction site; pure-helper fence walker extended additively. |
No property test for the closed-universe (HumanReviewReason ↔ HitlInterrupt.kind slugs) |
harden | AC-7 (i) + (ii) Hypothesis property over st.sampled_from(get_args(HumanReviewReason)); closed-universe draw, not arbitrary strings. |
| Single-file bundling temptation | harden | Anti-refactor #11 — per-AC test files; 10-file split mirrors S1-02 / S2-02 / S3-02 discipline. |
| Time-mocking discipline for AC-10 negative #3 (TTL exceeded) | harden | Notes-for-implementer §"Why 24-hour TTL" — operators inject mocked time via freezegun or a _now_utc() indirection in _hitl.py; the constant is Final[int] = 86400 (module-local, not in __all__). |
| Token-recomputation defense-in-depth ambiguity | harden | AC-3 (model-validator does it) + AC-5 step (3) (gate re-checks defense-in-depth) — .model_construct() can bypass validators; the gate MUST NOT trust the model alone. Documented explicitly in AC-5 step (3). |
match arm-counting test discipline |
harden | AC-4 (iii) — AST walker counts five case arms + case _: assert_never(verdict) drift guard. |
Lens 3 — Consistency¶
| Finding | Severity | Resolution |
|---|---|---|
EscalationReason amendment temptation |
block | Anti-refactor #4 — EscalationReason is byte-equal-unchanged; the S3-01 placeholder is replaced additively via emit_interrupt; Notes-for-implementer addresses three current-state scenarios with grep instructions. |
HumanReviewReason amendment temptation |
block | "This story does NOT" line — adding a fifth value is an ADR-0001 + S1-02 amendment, not this story's scope. AC-7 cross-table consistency enforces. |
_LEGAL_TRANSITIONS byte-equal-unchanged |
block | "This story does NOT" line — S1-02 owns the legal-transition table; this story uses existing edges (awaiting_human_review → plan_ready for resume; * → awaiting_human_review for emit). |
CheckpointStore Protocol byte-equal-unchanged |
block | "This story does NOT" line — uses existing append, tail_chain_head, iter_persisted_chain methods. |
| Phase-6.5 isolation directive | harden | AC-12 — public-surface allowlist grows by 8 names; the ResumeVerdict reject variants stay private; bench-harness-needed names (HitlInterrupt variants, ResumeInput, ApprovalToken, the four functions) are re-exported. |
_attempts/_lessons.md "two definitions of terminal" |
harden | AC-7 (iii) — explicit assertion that awaiting_human_review is in _TERMINAL_LEDGER_KINDS AND (awaiting_human_review, plan_ready) is in _LEGAL_TRANSITIONS. Notes-for-implementer §"Why _lessons.md's 'two definitions of terminal' matters here" expands. |
RequiresHumanReview (transforms/outcomes) vs AwaitingHumanReview (vuln_ledger) cross-domain identity |
harden | AC-7 (iv) — explicit NON-identity. Two unions, two umbrellas, two questions; the _lessons.md "Hydrated.kind MUST be a NEW closed tag" rule is the precedent. |
| Anti-refactor list absent | block | 12-item anti-refactor block — no registry, no ABC, no Strategy, no EscalationReason amendment, no time impurity in _hitl.py, no HitlResumeCache, no typed exception, no async, no AwaitingHumanReview payload coupling, no new FailedUnrecoverableReason value, no consolidated test file, no cryptographic-signature field. |
| Phase-9 forward dep (substrate-portability) un-addressed | harden | Out-of-scope statement; AC-11 substrate-agnostic parity test ensures the resume path works on both adapters; Phase-9 Postgres adapter inherits the protection by addition. |
Lens 4 — Design Patterns¶
| Finding | Severity | Resolution |
|---|---|---|
Plugin pattern temptation (HitlInterruptRegistry) |
block | Anti-refactor #1 — rule-of-three for HITL registries unmet (Phase-7 migration ships its own surface, not a shared registry). Closed-Annotated[..., Field(discriminator="kind")] is the right substrate. |
Strategy pattern temptation (ResumeStrategy Protocol) |
block | Anti-refactor #3 — directly contradicts final-design.md item 6 "No new trust bypass"; would let a future plugin substitute a weaker policy. |
| Composition over inheritance | block | Anti-refactor #2 — no BaseHitlInterrupt ABC; structural identity via kind: Literal[...] + model_config = _FROZEN_FORBID. |
| Functional core / imperative shell | harden | Pure helper _canonical_approval_message in _hitl.py (walked by AST no-side-effects fence); imperative shell in hitl.py (the gate + the boundary write). AC-2 + Notes-for-implementer §"Why time.time() / datetime.utcnow() not in _hitl.py". |
Newtype identifiers — never raw str for domain IDs |
harden | AC-2 — ApprovalToken newtype + smart constructor + AST chokepoint (mirrors BundleCacheKey precedent in identifiers.py); _NEWTYPE_REGISTRY drift test extended. |
| Smart constructor for the resumption-contract token | harden | AC-2 — mint_approval_token(workflow_id, chain_head, handoff_digest, ts_utc) is the SOLE sanctioned construction site; AST chokepoint enforces. |
| Make illegal states unrepresentable | harden | AC-3 model_validator(mode="after") recomputes the token from the canonical fields; AC-5 step (3) defense-in-depth; AC-9 prior_state_id legality check. |
| Tagged union / sum type for state | harden | AC-1 + AC-4 — closed Annotated[..., Field(discriminator="kind")] over _FROZEN_FORBID Pydantic variants. |
Sole-site policy gate (mirrors hydrate_or_fail) |
harden | AC-5 — resume_or_reject is the SOLE site mapping reject verdicts to FailedUnrecoverable; the canonical resume_with_full_verdict is the testable surface. Notes-for-implementer §"Why two surfaces". |
| Chain-of-responsibility temptation (multi-step gate as a Chain pattern) | nit | AC-5 — eight-step sequence is a flat function with early returns; Chain pattern would force a Step ABC + a ChainBuilder for what's a linear walk. Below Rule 2's three-similar-lines threshold; rejected. |
| Pure-core / impure-shell file split | harden | _hitl.py (pure) + hitl.py (impure); the existing _chain.py / _replay.py / replay.py precedent. |
__slots__ lock on the gate |
nit | Not added — resume_or_reject is a free function, not a class. The S2-02 ReplayVerifier.__slots__ = ("_store",) precedent only applies to the class-shaped surface; we don't need a class here. |
Conflict resolution (priority: Consistency > Coverage > Test-Quality > Design-Patterns)¶
-
HitlInterruptRegistryvs closed-Pydantic sum type (Design-Patterns potential registry temptation vs Consistency reading of S1-02 / S2-02 / S3-02 anti-refactor precedent + final-design.md item 5 closed-union mandate). Resolution: closed sum type (Consistency wins). The rule-of-three threshold for a registry over HITL surfaces is unmet; per-task-class HITL surfaces live in per-plugin modules per ADR-0002. -
EscalationReasonamendment vs additive emit-interrupt (Consistency reading of S3-01 placeholder current state vs Coverage need for clean cross-domain semantics). Resolution: additive (Consistency wins).EscalationReasonis byte-equal-unchanged; the placeholder is replaced viaemit_interrupt(...); Notes-for-implementer addresses the three current-state grep scenarios. -
One-surface gate vs two-surface gate (Design-Patterns reading of S2-02 precedent vs Coverage convenience for the orchestrator). Resolution: two surfaces (mirrors S2-02 verbatim).
resume_with_full_verdictis canonical and testable;resume_or_rejectis the orchestrator convenience. -
Time impurity in
_hitl.py(Test-Quality reading of mockability vs Design-Patterns purity discipline). Resolution: purity wins (Design-Patterns aligned with the S2-01 sanitize purity precedent). The orchestrator boundary passesnow_utc()intomint_approval_token;_hitl.pyhas no FS/time side effects. Tests mock the orchestrator boundary, not the pure helper. -
HumanReviewReasonLiteral byte-equal-unchanged vs amendment for HITL completeness (Consistency reading of S1-02 closed-set ownership vs Coverage temptation for a fifth slug). Resolution: byte-equal-unchanged (Consistency wins). The four S1-02 values cover the universe; any fifth value is an ADR-0001 + S1-02 amendment, not this story's scope. AC-7 cross-table consistency enforces. -
ResumeVerdictvariant exposure (Coverage reading of bench-harness completeness vs Consistency reading of Phase-6.5 isolation directive). Resolution: keepResumeVerdictvariants private (Consistency wins, mirrors S2-02ReplayVerdictdiscipline). The bench harness has access toHitlInterruptvariants +ResumeInput+ the four functions; the resume-verdict reject variants live internally. -
AC-8 fence walker depth (Test-Quality reading of mutation resistance vs Design-Patterns simplicity). Resolution: deep walker over the full function-graph (Test-Quality wins, with a Design-Patterns concession: factor the walker into
tests/fence/_ast_helpers.pyto keep the test file thin). Notes-for-implementer §"Why the AST fence walks the FULL function-graph" expands. -
AC-10 negative scenarios in the same file vs separate files (Test-Quality reading of discoverability vs Coverage parametrize density). Resolution: same file (the positive scenario sets up state; the negatives mutate it incrementally). This is the inverse of the per-AC split discipline — the scenarios are tightly coupled at the fixture level, so co-location aids debugging. Documented in Files-to-touch.
No NEEDS RESEARCH flag remained after critic synthesis.
Stage 3 — Researcher¶
Skipped. No NEEDS RESEARCH flag from any lens remained unresolved after Stage-2 synthesis. Every pattern this story prescribes has an in-repo precedent: closed-Annotated[..., Field(discriminator="kind")] over _FROZEN_FORBID Pydantic variants (S1-02 VulnLedgerState, S2-02 ReplayVerdict); SOLE-site integrity-policy gate (hydrate_or_fail); AST "remains paused on reject" fence (test_hydrate_no_state_construction.py); pure-core / imperative-shell file split (_chain.py + _replay.py + replay.py); newtype smart constructor + AST chokepoint (BundleCacheKey + compose_bundle_cache_key); cross-table consistency Hypothesis property (S1-02 backward-consistency); parity matrix across adapters (S2-01 + S2-02). Researching arXiv / library docs would have added zero signal.
Stage 4 — Edits applied¶
Pre-validation story (17 lines)¶
# S4-01 — HITL interrupt and resume
**Status:** Ready
**Goal:** Emit typed HITL interrupts and validate resume input before continuation.
## Acceptance criteria
- Retry exhaustion enters `AwaitingHumanReview`.
- Resume rejects stale or malformed approval payloads.
- Approved resume continues from latest verified checkpoint.
## TDD plan
Red: stale approval and resume tests.
Green: interrupt payload + validator.
Refactor: share timestamp and evidence validation.
Post-validation story (HARDENED — see file)¶
| Section | Before | After |
|---|---|---|
| Status line | Ready |
HARDENED + Validated: line + Depends on: (five explicit cross-story deps + a "This story does NOT" five-bullet disambiguation) |
| Goal | 1 sentence | 1 paragraph naming the substrate + 4 numbered concerns (HitlInterrupt union, ResumeInput model + ApprovalToken newtype, ResumeVerdict + resume_or_reject, retry-exhaustion emission + S3-01 placeholder replacement) + 1 paragraph explicitly placing this story as the fourth-concrete-consumer of the typed-substrate-and-sole-site-policy-gate Phase-6 pattern |
| References | absent | 16-entry block citing final-design.md / phase-arch-design.md / ADRs/0001 / ADRs/0003 / High-level-impl.md / sibling stories S1-01 / S1-02 / S2-01 / S2-02 / S3-01 / canonical siblings replay.py / vuln_ledger.py / __init__.py / fence file precedents / _lessons.md four lessons |
| Acceptance criteria | 3 dash bullets (0 checkboxes) | 14 numbered checkbox ACs across 5 labeled sub-sections (typed interrupt; resumption contract; verdict + gate; cross-table consistency + fences; closeout) |
| Files to touch | absent | 19-line list — 2 new source files (hitl.py create + _hitl.py create), 1 source modify (__init__.py), 1 identifier add (identifiers.py), 4 new unit test files, 3 new integration test files, 1 contract modify, 3 new fence files, 1 fence-fixture, 3 fence modifies, 1 contract-snapshot modify, 1 newtype-drift modify, 1 ADR amendment |
| TDD plan | 3 sentences | Red phase (14-step sequence, one per AC) + Green (12-step minimum-code sequence) + Refactor (4-item cleanup list) + Anti-refactor (12 items) |
| Out of scope | absent | 8-item list — LangGraph routing (S3-01); CLI resume subcommand (Phase-7+); multi-reviewer/quorum (Phase-9); cryptographic-signature tokens (Anti-refactor #12); cross-substrate token portability (naturally satisfied); approval-token revocation (chain-head drift handles it); e2e scenarios.yaml (S6-01); workflow-replay-determinism property (S6-01) |
| Notes for implementer | absent | 9-paragraph block — two-surfaces rationale, canonical-message field rationale, 24-hour TTL rationale, WorkflowId re-export rationale, two-definitions-of-terminal discipline, EscalationReason non-amendment rationale with 3-scenario grep instructions, AST fence walker depth, per-AC test files rationale, no-cassette-refresh-needed, 12-step implementation order |
Verdict¶
HARDENED — every four-lens finding either landed as an AC, an Anti-refactor item, an Out-of-scope statement, a Notes-for-implementer paragraph, or a Conflict-resolution rationale. All eight conflicts resolved with explicit priority-order reasoning. No NEEDS RESEARCH flag remained open. The story is ready for phase-story-executor.