Skip to content

Commit 182bbde

Browse files
os-litantclaude
andauthored
fix(runtime,spec): the resume door asks the engine whether a status-less exit is repairable (#18792)
Fixes #17541 Clause-②: yes (widening) Ruling: batch #148 item 5, letter **1** (comment `5716043121`), maintainer 「同意」 2026-09-17T14:27Z. ## The defect, as the ruling asked for it: a WIRE reading Step one of the ruling is a measurement through the HTTP route, not through the engine. Driven through `HttpDispatcher.handleAutomation` on a parked two-level delegation — the parent parked at its `subflow` node, the child parked at a pausing node, the child's downstream node then throwing — `POST /automation/parent_flow/runs/:parentRunId/resume` answered: **BEFORE** (`origin/main` at `f1c9bb3056`, the defect reproduced on trunk): ```json 400 { "success": false, "error": { "code": "FLOW_FAILED", "message": "subflow run 'run_8a89ceab…' (child_flow) failed: update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found", "httpStatus": 400, "details": { "runId": "run_240f641d…", "repairable": false } } } ``` **AFTER** (this branch): ```json 400 { "success": false, "error": { "code": "FLOW_FAILED", "message": "subflow run 'run_bc8d989f…' (child_flow) failed: update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found", "httpStatus": 400, "details": { "runId": "run_337c1bca…", "repairable": true } } } ``` At that same instant, on the same engine, both readings taken in the same probe run: ```text engine.inspectConsumedSuspension(parentRunId) = { repairable: true, runId: "run_…", flowName: "parent_flow", nodeId: "sub", correlation: "subflow:run_…", witness: "journal", consumedAt: "…" } engine.restoreConsumedSuspension(parentRunId, { requestedBy: "ops" }) = { restored: true, chain: [childRunId, parentRunId] } ``` So the wire told an operator not to retry a repair that works, and the schema's own `.describe()` stated the opposite reason — projected verbatim into the generated reference page. ## What changed **`packages/spec` — `IAutomationService` declares the read-only member.** `inspectConsumedSuspension(runId)`, optional, exactly the shape the engine already implements publicly (`AutomationEngine.inspectConsumedSuspension`, landed for #15358). The declared result is deliberately narrower than the implementation's, on the precedent the sibling `restoreConsumedSuspension` set (#16495 route (i)): `{ repairable: boolean; runId: string; reason?: string }`, with `reason` typed as the string the implementation answers rather than as an enumeration this contract would have to keep in step with. The engine's wider `ConsumedSuspensionInspection` satisfies it under `implements` — proved below, in both directions. **`packages/runtime` — the resume door consults the declared contract.** On a `400 FLOW_FAILED` whose result carries a `status`, that stamp still decides and the engine is not consulted at all. On a result that stamps none, the door asks the declared optional member and relays its `repairable`. ⛔ The door never probes an undeclared member: the member is on `IAutomationService` first, and the door reads it through that optionality, the same way it already reads `resume`. Every way of not getting an answer is FAIL-CLOSED — a service that declares no inspection member answers `false` exactly as it did before, and an inspection that REJECTS (a store it could not read) answers `false` and says so once at `warn`. An unreadable store is UNKNOWN, not "nothing to restore", and it is never allowed to replace the `400` the caller asked for with a `500`. ⛔ The fence the nested-chain work was dispatched with is untouched: a cascade-failed ancestor is still never STAMPED `'stranded'`. Its repairability is carried by the journal and REPORTED by the inspection, which is exactly why the door has to ask instead of reading a word. ⛔ And no new `AutomationResult.status` member is minted (ruled-out option 2): there is nothing new for a client to learn, and `details.repairable` is the member a client was already told to branch on. **Docs.** `ResumeFailureDetailsSchema.repairable`'s `.describe()` is rewritten to the truth; `content/docs/references/api/automation-api.mdx` is REGENERATED with the repo's own tooling (`pnpm --filter @objectstack/spec check:generated --fix`, which proved exactly that one artifact stale and regenerated only it — a one-line diff). The two hand-written pages were re-read for the same sentence and both carried it, so both are corrected by hand: `content/docs/automation/flows.mdx` said `repairable` is "`true` exactly when `status` is `stranded`", and `content/docs/api/client-sdk.mdx` asserted `verdict.data.status === 'stranded'` inside its `if (verdict.data.repairable)` sample. ## Tests Evidence below is from the final commit, `228275a473`. **A test that fails without the change and passes with it, plus its controls.** New, in two layers: - `packages/verify/src/automation-resume-delegation-repairable.test.ts` — the wire, through the real engine and the real route. Three cases: the DELEGATION exit (`repairable: true`, no `status`, the whole `details` parsing under `ResumeFailureDetailsSchema`, and the operator verb then accepting exactly that run and the re-issued resume completing the tree); a CONTROL that the **non-delegation** stranded exit is unchanged (`status: 'stranded'`, `repairable: true`); and a FIRING CONTROL — a delegation whose leaf is NOT repairable answers `repairable: false` on that same status-less arm. - `packages/runtime/src/domains/automation-resume-delegation-repairable.test.ts` — the door's shaping with a fake service, so the arms a real engine will not produce on demand are reachable by name: the two fail-closed arms, and two controls proving a stamped `'stranded'` / `'failed'` is answered by the STAMP and the engine is never asked. The pre-existing `packages/runtime/src/domains/automation-resume-stranded-details.test.ts` is left **untouched** on purpose and is the standing control that every non-delegation exit answers exactly what it did. **Reverse verification — behaviour.** The pre-change expression was put back on the committed tree, proven on disk (injected marker count 1, deleted call count 0, blob hash moved off HEAD's), `@objectstack/runtime` rebuilt, and the mutation proven to have reached the artifact the suite consumes (`node scripts/ablation-dist-preflight.mjs runtime … ` — marker present in 2 built files). Direction observed, as predicted: **turned red, and only there** — `1 failed | 4 passed`, the failure being the DELEGATION wire case, with both controls and the two pre-existing wire cases still green. Restore leg: source restored from `HEAD`, whole-tree `git status --porcelain` clean, blob hash equal to `HEAD`'s, rebuilt, `--absent` preflight confirming the marker is gone from all 6 built files, suite green again (5 passed). **Reverse verification — types.** `AutomationEngine implements IAutomationService`, so the new declaration is enforced on the implementer. With the engine's `ConsumedSuspensionInspection` mutated to drop `runId` from its repairable arm, `pnpm --filter @objectstack/service-automation exec tsc --noEmit` exits **1** naming the member: ```text src/engine.ts(8133,11): error TS2416: Property 'inspectConsumedSuspension' in type 'AutomationEngine' is not assignable to the same property in base type 'IAutomationService'. ``` Restored, the same command exits **0**. That is also the proof the typecheck reads the rebuilt spec `.d.ts` rather than a cached one. **Suites — both projects run where a package has two, exit codes read from `$?` after a redirect, never through a pipe:** | command | exit | |:---|:---| | `pnpm --filter @objectstack/spec test` (`--project local`) | 0 — 486 files, 14017 tests | | `pnpm --filter @objectstack/spec test:repo` (`--project repo`) | 0 — 31 files, 536 tests | | `pnpm --filter @objectstack/runtime test` (`--project local`) | 0 — 266 files, 3667 passed, 1 skipped | | `pnpm --filter @objectstack/runtime test:repo` (`--project repo`) | 0 — 2 files, 69 tests | | `pnpm --filter @objectstack/verify test` | 0 — 110 tests | | `pnpm --filter @objectstack/spec typecheck` | 0 | | `pnpm --filter @objectstack/runtime typecheck` | 0 | | `pnpm --filter @objectstack/verify typecheck` | 0 | | `pnpm --filter @objectstack/service-automation typecheck` | 0 | | `pnpm --filter '@objectstack/verify^...' build` | 0 | **Gates run locally** (all exit 0): `pnpm --filter @objectstack/spec check:generated` (15 of 15 up to date after the regeneration), `check:nul-bytes`, `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check:objectui-changeset`, `check:pm-widening-tells`, `check-closing-keyword-parity`, `check-spec-docblock-symbol-anchors`, `check-doc-frontmatter`, `check:docs-single-h1`, `check:doc-anchors`, `check-docs-section-name`, `docs-audit/check-affected-docs`, `check:route-envelope`, `check:dispatcher-error-vocabulary`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check-undeclared-dep-imports`, `check:tier-file-adoption`, `check:type-check-coverage`, `check:type-check-debt`. The three changeset gates first exited 1 with `no merge base between 'origin/main' and 'HEAD'` — a shallow-clone PREREQUISITE, not a verdict. `git fetch --deepen 300 origin main` restored the merge base (`f1c9bb3056`) and all three then exited 0. **Lint: the whole population, not a narrowing.** `node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json` exits **0** over **6834** files (count read from the JSON output), 0 errors and 0 warnings. **NOT measured here, deliberately:** the rest of CI's gate farm (the derivation names 107 runnable commands plus 50 artifact-roster families, 10 wide-population families and 6 path-scheduled CI jobs), and the `Temporal Conformance` / `Dogfood` / `Build Docs` jobs, which have no local invocation. ## Acceptance notes Two observations found on the way, neither filed — each is an omission rather than an error, and neither is a reproducible defect, a broken declared contract, or an authoring trap: - `registerSubflowNode` is exported from `packages/services/service-automation/src/builtin/index.ts` but is not re-exported from that package's root `index.ts`, while five sibling registrars are. Nothing is broken by it: `installBuiltinNodes` reaches the same executor and is what the new wire test uses. Next party to meet it: an out-of-package test author who wants the subflow node alone. - `ResumeFailureDetailsSchema.status`'s own describe names "a subflow child that failed terminally" as the status-less case and does not also name the delegation frame. Incomplete, not false, and the ruling named only the `repairable` describe. Next party to meet it: whoever next edits that enum's text. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8904880 commit 182bbde

9 files changed

Lines changed: 689 additions & 31 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
the resume door's `repairable` is answered by the engine on the exits that stamp no status — `IAutomationService` declares the read-only `inspectConsumedSuspension` (#17541)
7+
8+
Clause-②: yes (widening)
9+
10+
The resume route's `400 FLOW_FAILED` details computed `repairable` as the single
11+
expression `status === 'stranded'`. That word is stamped on exactly one exit —
12+
the run that consumed its OWN pause and then threw downstream. The subflow
13+
DELEGATION exit stamps nothing on purpose: a caller resumes the PARENT, the
14+
signal is forwarded down, the child strands, and the parent frame answers
15+
`{ success: false, error, durationMs }`, because nothing re-arms an ancestor by
16+
resuming it and stamping `'stranded'` there would send an operator to retry a
17+
recovery that cannot succeed.
18+
19+
Since the nested-chain restore landed, that parent's consumed pause IS
20+
journalled and one `restoreConsumedSuspension(parentRunId)` re-arms the whole
21+
chain leaf-first. So the wire answered `repairable: false` about a run the
22+
operator verb WILL repair, and a client written exactly as the reference page
23+
instructs closed it as terminal. Measured through the HTTP route, before and
24+
after, on the same parked delegation:
25+
26+
```json
27+
before 400 { "error": { "code": "FLOW_FAILED",
28+
"details": { "runId": "run_…", "repairable": false } } }
29+
after 400 { "error": { "code": "FLOW_FAILED",
30+
"details": { "runId": "run_…", "repairable": true } } }
31+
```
32+
33+
…while at that same instant the engine answered
34+
`inspectConsumedSuspension(runId) → { repairable: true, witness: 'journal' }`
35+
and `restoreConsumedSuspension(runId) → { restored: true, chain: [child, parent] }`.
36+
37+
**`@objectstack/spec` — additive, `minor`.** `IAutomationService` declares the
38+
optional read-only member `inspectConsumedSuspension(runId)`, which
39+
`AutomationEngine` already implements publicly: would the restore verb have a
40+
consumed suspension to put back for this run? It re-arms nothing and reads the
41+
same two witnesses that verb reads, so what it calls repairable IS what that
42+
verb restores. The declared result is deliberately narrower than the
43+
implementation's, the way `restoreConsumedSuspension`'s already is — `reason` is
44+
typed as the string the implementation answers, not as an enumeration this
45+
contract would have to keep in step, and the engine's wider type satisfies it
46+
under `implements`. `ResumeFailureDetailsSchema.repairable`'s `.describe()` is
47+
rewritten to the truth and the generated reference page regenerated with it. No
48+
key is added, renamed or retired on any wire schema.
49+
50+
**`@objectstack/runtime` — the door.** On a `400 FLOW_FAILED` whose result
51+
carries a `status`, that stamp still decides, and the engine is not consulted at
52+
all. On a result that carries none, the door asks the declared member and relays
53+
its `repairable`. Both ways of not getting an answer are FAIL-CLOSED: a service
54+
that declares no inspection member answers `false` exactly as it did before, and
55+
an inspection that REJECTS (a store it could not read) answers `false` and says
56+
so once at `warn` — an unreadable store is UNKNOWN, not "nothing to restore",
57+
and it is never allowed to replace the `400` the caller asked for with a `500`.
58+
59+
⛔ The fence is untouched: a cascade-failed ancestor is still never STAMPED
60+
`'stranded'`. Its repairability is carried by the journal and REPORTED by the
61+
inspection, which is exactly why the door asks instead of reading a word. ⛔ And
62+
no new `AutomationResult.status` member is minted for this exit — there is
63+
nothing new for a client to learn, and `details.repairable` is the member a
64+
client was already told to branch on.

‎content/docs/api/client-sdk.mdx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -767,17 +767,22 @@ try {
767767
if (!isApiError(err) || err.code !== 'FLOW_FAILED') throw err;
768768
const verdict = ResumeFailureDetailsSchema.safeParse(err.details);
769769
if (verdict.success && verdict.data.repairable) {
770-
// verdict.data.status === 'stranded' — verdict.data.runId names the run
771-
// an operator can re-arm; offer that instead of closing as terminal.
770+
// verdict.data.runId names the run an operator can re-arm; offer that
771+
// instead of closing as terminal. Read `repairable` alone: `status` is
772+
// absent on the subflow delegation failure, which is repairable without
773+
// ever carrying the word `stranded`.
772774
}
773775
}
774776
```
775777

776778
`repairable` is always present on the resume door's `400` — `false` on a plain
777779
terminal failure, deliberately, so an absent member reads as an older server
778-
rather than as "not repairable". `status` is the engine's own stamp
779-
(`'stranded'` or `'failed'`), forwarded when the engine set one and never
780-
invented by the door. The code stays `FLOW_FAILED`: there is no `FLOW_STRANDED`
780+
rather than as "not repairable". It is not a reading of `status`: where the
781+
engine stamped one, that stamp decides; where it stamped none — the subflow
782+
delegation failure — the door asks the engine's read-only inspection, so
783+
`repairable: true` can arrive with no `status` beside it. `status` is the
784+
engine's own stamp (`'stranded'` or `'failed'`), forwarded when the engine set
785+
one and never invented by the door. The code stays `FLOW_FAILED`: there is no `FLOW_STRANDED`
781786
sibling, and the trigger door's `400` carries `errorMessage` / `summary` only —
782787
it never resumes, so "repairable" has no referent there.
783788

‎content/docs/automation/flows.mdx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -790,11 +790,18 @@ per-node `summary`:
790790
"errorMessage": "…", "summary": { … } } } }
791791
```
792792

793-
Branch on `details.repairable`, never on the message: it is `true` exactly when
794-
`status` is `stranded`, and it is **always present** on this route's `400` —
795-
`false` on a plain terminal failure, so an absent member reads as an older
796-
server rather than as "not repairable". `status` is the engine's own stamp,
797-
forwarded when it set one and never invented by the route. The code does not
793+
Branch on `details.repairable`, never on the message. It is **always present**
794+
on this route's `400`, and it is the engine's own answer rather than a reading
795+
of the status word: where the engine stamped a `status`, that stamp decides
796+
(`stranded`); where it stamped none, the route asks the engine's read-only
797+
inspection and relays the verdict. The second case is a real exit, not a
798+
fallback — a subflow **delegation** failure carries no status, because nothing
799+
re-arms an ancestor by resuming it, and yet the ancestor's consumed pause is
800+
journalled and one operator restore re-arms that whole chain, so it answers
801+
`repairable: true` with no `status` beside it. `false` on a plain terminal
802+
failure, so an absent member reads as an older server rather than as "not
803+
repairable". `status` is the engine's own stamp, forwarded when it set one and
804+
never invented by the route. The code does not
798805
change (there is no `FLOW_STRANDED`), and the trigger route's `400` carries
799806
`errorMessage` / `summary` only — it never resumes, so the question has no
800807
referent there. The shape is `ResumeFailureDetailsSchema` in

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ const result = AutomationApiErrorCode.parse(data);
583583
| :--- | :--- | :--- | :--- |
584584
| **runId** | `string` | ✅ | The run the resume was addressed to - the run that failed, and on the `stranded` arm the run an operator verb can re-arm. Named so a caller acts on an identifier instead of parsing one out of the message |
585585
| **status** | `Enum<'failed' \| 'stranded'>` | optional | The engine's own lifecycle verdict for the run, forwarded verbatim when the producer stamped one and never synthesised by the door - absent when the engine reported no status (a subflow child that failed terminally, an engine that predates the discriminator). `stranded` is the terminally-failed-but-repairable run of `AutomationResult.status`; `failed` says the run ran and was rejected. These two terminal-failure members of that union are the only ones that can reach a 400 |
586-
| **repairable** | `boolean` | ✅ | Whether the engine says this run can still be re-armed by an operator verb - `true` exactly when `status` is `stranded`, derived from the engine's discriminator and never from the message text. Always present on this arm: `false` is the honest answer for every other exit, the ones that report no status included, because an absent member would be indistinguishable from a server that predates this field, and promising a repair verb that will refuse is worse than promising nothing |
586+
| **repairable** | `boolean` | ✅ | Whether the engine says this run can still be re-armed by an operator verb. Answered from the engine, two ways, and never from the message text: where the engine stamped a `status`, that word decides (`stranded` is the run whose OWN pause a resume consumed before a downstream node threw); where it stamped none, the door asks the engine's read-only inspection (`IAutomationService.inspectConsumedSuspension`) and relays its verdict. The second half is not a fallback but the honest answer for a real exit: a subflow DELEGATION failure carries no status, because nothing re-arms an ancestor by resuming it, and yet the ancestor's consumed pause is journalled and the restore verb re-arms that chain as one unit. Always present on this arm: an absent member would be indistinguishable from a server that predates this field. Every way of not getting an answer is fail-closed - a service that declares no inspection member, and a store the inspection could not read - because promising a repair verb that will refuse is worse than promising nothing |
587587

588588

589589
---
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #17541 — the resume door's `repairable` is answered by the ENGINE on the
5+
* exits that stamp no `status`, instead of being read off a stamp that is not
6+
* there (the maintainer's ruling on this card, 2026-09-17, letter 1).
7+
*
8+
* ## The defect, as a wire reading
9+
*
10+
* `repairable` used to be the single expression `status === 'stranded'`. That
11+
* word is stamped on exactly one exit — the run that consumed its OWN pause
12+
* and then threw downstream. The subflow **delegation** exit deliberately
13+
* stamps nothing: a caller resumes the PARENT, `resumeInternal` forwards the
14+
* signal down, the child strands, and the parent frame answers
15+
* `{ success: false, error, durationMs }`, because nothing re-arms an ancestor
16+
* by resuming it and stamping `'stranded'` there would send an operator to
17+
* retry a recovery that cannot succeed. Since #15222 that parent's consumed
18+
* pause IS journalled and `restoreConsumedSuspension(parentRunId)` re-arms the
19+
* chain as one unit — so the wire answered `repairable: false` about a run the
20+
* operator verb WILL repair, and a client written exactly as the docs instruct
21+
* closed it as terminal.
22+
*
23+
* ## What this file pins, and what it deliberately does NOT
24+
*
25+
* This is the DOOR's shaping, with a fake service, so every arm is reachable
26+
* by name — including the two that a real engine will not produce on demand (a
27+
* host with no inspection member; a store the inspection cannot read). The
28+
* end-to-end fact, driven through the real engine and the HTTP route, is
29+
* `@objectstack/verify`'s `automation-resume-delegation-repairable.test.ts`;
30+
* the engine's own halves are `service-automation`'s
31+
* `nested-strand-chain-restore.test.ts` and
32+
* `consumed-suspension-inspection.test.ts`.
33+
*
34+
* ⛔ The fence #15222 was dispatched with is untouched and is not re-pinned
35+
* here: a cascade-failed ancestor is still never STAMPED `'stranded'`. Its
36+
* repairability is carried by the journal and REPORTED by the inspection,
37+
* which is precisely why the door has to ask instead of reading a word.
38+
*
39+
* ⛔ And the door asks a member the CONTRACT declares — `IAutomationService`
40+
* gained the optional read-only `inspectConsumedSuspension` for this card.
41+
* Reaching for an undeclared member would be the fail-open shape the route's
42+
* own `501` arm exists to prevent, so every way of not getting an answer here
43+
* is FAIL-CLOSED: no member, and a rejected read, both answer `false`.
44+
*
45+
* The sibling file `automation-resume-stranded-details.test.ts` is the CONTROL
46+
* for this change and is deliberately left untouched: every non-delegation
47+
* exit it pins — the stamped `'stranded'`, the stamped `'failed'`, the
48+
* status-less arm on a service with no inspection member, the regex control,
49+
* the trigger door's absent member — still answers exactly what it did.
50+
*/
51+
52+
import { describe, it, expect, vi, afterEach } from 'vitest';
53+
54+
import { ResumeFailureDetailsSchema } from '@objectstack/spec/api';
55+
import type { AutomationResult, IAutomationService } from '@objectstack/spec/contracts';
56+
57+
import { HttpDispatcher } from '../http-dispatcher.js';
58+
59+
const CTX = { request: {}, executionContext: { userId: 'user_1' } } as any;
60+
const RESUME = '/parent_flow/runs/run_parent/resume';
61+
62+
/**
63+
* The engine's answer on the delegation exit, measured through the real
64+
* engine while this card was written: the parent frame carries no `status`,
65+
* no `errorMessage` and no `summary` — only the subflow failure text.
66+
*/
67+
const DELEGATION_FRAME: AutomationResult = {
68+
success: false,
69+
error: "subflow run 'run_child' (child_flow) failed: update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found",
70+
durationMs: 7,
71+
};
72+
73+
type Inspect = NonNullable<IAutomationService['inspectConsumedSuspension']>;
74+
75+
function makeDispatcher(resumeResult: AutomationResult, inspect?: Inspect) {
76+
const spies = {
77+
resume: vi.fn(async () => resumeResult),
78+
...(inspect ? { inspectConsumedSuspension: vi.fn(inspect) } : {}),
79+
};
80+
const services: Record<string, unknown> = { automation: spies };
81+
const resolve = (name: string) => services[name];
82+
const kernel: any = {
83+
getService: resolve,
84+
getServiceAsync: async (name: string) => resolve(name),
85+
context: { getService: resolve },
86+
};
87+
return { dispatcher: new HttpDispatcher(kernel), spies };
88+
}
89+
90+
afterEach(() => { vi.restoreAllMocks(); });
91+
92+
describe('#17541 — the status-less resume exit asks the engine whether the run is still repairable', () => {
93+
it('DELEGATION — a frame with no status answers repairable: true when the engine holds the consumed suspension', async () => {
94+
const { dispatcher, spies } = makeDispatcher(
95+
DELEGATION_FRAME,
96+
async (runId: string) => ({ repairable: true, runId }),
97+
);
98+
99+
const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX);
100+
101+
// The door asked about the run it was addressed to — the path's
102+
// `:runId`, which on this exit IS the run whose pause was consumed.
103+
expect(spies.inspectConsumedSuspension).toHaveBeenCalledWith('run_parent');
104+
expect(result.response?.status).toBe(400);
105+
const error = result.response?.body?.error;
106+
expect(error?.code).toBe('FLOW_FAILED');
107+
// ⛔ Still no `status`: the door relays the producer's stamp and the
108+
// producer stamped none. `repairable` is the answer, not the word.
109+
expect(error?.details).toEqual({ runId: 'run_parent', repairable: true });
110+
expect(ResumeFailureDetailsSchema.safeParse(error?.details).success).toBe(true);
111+
// Not prose: the message names the subflow failure, not the verdict.
112+
expect(error?.message).not.toMatch(/repairable/i);
113+
});
114+
115+
it('FIRING CONTROL — the same status-less arm answers false when the engine says the suspension is gone', async () => {
116+
// Without this the pin above would pass over a door that simply
117+
// flipped the status-less arm to `true` and never asked anything.
118+
const { dispatcher, spies } = makeDispatcher(
119+
DELEGATION_FRAME,
120+
async (runId: string) => ({ repairable: false, runId, reason: 'NO_CONSUMED_SUSPENSION' }),
121+
);
122+
123+
const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX);
124+
125+
expect(spies.inspectConsumedSuspension).toHaveBeenCalledWith('run_parent');
126+
expect(result.response?.status).toBe(400);
127+
expect(result.response?.body?.error?.details).toEqual({ runId: 'run_parent', repairable: false });
128+
});
129+
130+
it('FAIL-CLOSED — a service that declares no inspection member answers false, exactly as it did before this card', async () => {
131+
const { dispatcher } = makeDispatcher(DELEGATION_FRAME);
132+
133+
const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX);
134+
135+
expect(result.response?.status).toBe(400);
136+
expect(result.response?.body?.error?.details).toEqual({ runId: 'run_parent', repairable: false });
137+
});
138+
139+
it('FAIL-CLOSED — an inspection that REJECTS still answers the 400 it was asked for, with repairable false and one warning', async () => {
140+
// An unreadable store is UNKNOWN, not "nothing to restore" — so it is
141+
// said out loud once. ⛔ And it never replaces the answer: the caller
142+
// asked about a run that failed, and turning a store outage into a
143+
// 500 would withhold that in order to report a detail.
144+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
145+
const { dispatcher } = makeDispatcher(
146+
DELEGATION_FRAME,
147+
async () => { throw new Error('connection reset'); },
148+
);
149+
150+
const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX);
151+
152+
expect(result.response?.status).toBe(400);
153+
expect(result.response?.body?.error?.code).toBe('FLOW_FAILED');
154+
expect(result.response?.body?.error?.details).toEqual({ runId: 'run_parent', repairable: false });
155+
const said = warn.mock.calls.map(c => String(c[0])).filter(l => l.includes('inspectConsumedSuspension'));
156+
expect(said).toHaveLength(1);
157+
expect(said[0]).toContain('connection reset');
158+
expect(said[0]).toContain('run_parent');
159+
});
160+
161+
it('CONTROL — a stamped `stranded` is answered by the STAMP: repairable true, and the engine is never asked', async () => {
162+
const { dispatcher, spies } = makeDispatcher(
163+
{ success: false, error: 'tail blew up', durationMs: 12, status: 'stranded' },
164+
async (runId: string) => ({ repairable: false, runId, reason: 'NO_CONSUMED_SUSPENSION' }),
165+
);
166+
167+
const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX);
168+
169+
// The inspection would have answered `false` here. It is not consulted,
170+
// so the stamp is demonstrably what decided — and the pre-existing
171+
// exits keep costing exactly one engine call.
172+
expect(spies.inspectConsumedSuspension).not.toHaveBeenCalled();
173+
expect(result.response?.body?.error?.details).toEqual({
174+
runId: 'run_parent', status: 'stranded', repairable: true,
175+
});
176+
});
177+
178+
it('CONTROL — a stamped `failed` is answered by the STAMP: repairable false, and the engine is never asked', async () => {
179+
const { dispatcher, spies } = makeDispatcher(
180+
{ success: false, error: 'rejected', durationMs: 3, status: 'failed' },
181+
async (runId: string) => ({ repairable: true, runId }),
182+
);
183+
184+
const result = await dispatcher.handleAutomation(RESUME, 'POST', {}, CTX);
185+
186+
// The inspection would have answered `true` here — a stamped `failed`
187+
// is a run that ran and was rejected, and no reading of a leftover
188+
// snapshot is allowed to overturn the producer's own verdict.
189+
expect(spies.inspectConsumedSuspension).not.toHaveBeenCalled();
190+
expect(result.response?.body?.error?.details).toEqual({
191+
runId: 'run_parent', status: 'failed', repairable: false,
192+
});
193+
});
194+
});

0 commit comments

Comments
 (0)