From 2c4bc2903701f758f1c6c5aa107a3c597888e9de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:50:03 +0000 Subject: [PATCH] feat(spec): name the terminally-failed-but-repairable run on AutomationResult.status as 'stranded' Contract half of the #13937 shape-4 ruling (maintainer 2026-09-01): the run whose resume consumed its suspension and then had a downstream node throw is recorded as failed and can be re-armed only by an explicit operator verb. `AutomationResult.status` now carries `'stranded'` beside `'completed' | 'paused' | 'failed'`, the wire mirror `TriggerFlowResponseSchema.data.status` carries the same four, and `contracts/automation-result-status.pin.test.ts` binds the two at the type level and the value level and reads the JSDoc that names the condition. No engine, route or client behaviour changes; plugin-approvals' report-only `StrandedRunState` is deliberately not promoted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- .../automation-result-stranded-status.md | 22 +++ .../docs/references/api/automation-api.mdx | 2 +- packages/spec/src/api/automation-api.zod.ts | 13 +- .../automation-result-status.pin.test.ts | 140 ++++++++++++++++++ .../spec/src/contracts/automation-service.ts | 25 +++- 5 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 .changeset/automation-result-stranded-status.md create mode 100644 packages/spec/src/contracts/automation-result-status.pin.test.ts diff --git a/.changeset/automation-result-stranded-status.md b/.changeset/automation-result-stranded-status.md new file mode 100644 index 0000000000..ee1118d6d9 --- /dev/null +++ b/.changeset/automation-result-stranded-status.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): name the terminally-failed-but-repairable run on `AutomationResult.status` — `'stranded'` (#14384, contract half of #13937) + +`AutomationResult.status` (`contracts/automation-service.ts`) gains a fourth +member beside `'completed' | 'paused' | 'failed'`: **`'stranded'`** — the run +whose resume CONSUMED its suspension and then had a downstream node throw, so +the run is recorded as failed and can be re-armed only by an explicit operator +verb (#13909's condition; the #13937 shape-4 ruling, maintainer 2026-09-01 +「命名同批定」). The wire mirror `TriggerFlowResponseSchema.data.status` +(`api/automation-api.zod.ts`) carries the same four, and a pin test binds the +two at the type level and the value level. + +Additive: no existing literal changes meaning, `'failed'` still says "the run +ran and was rejected", and no engine, route or client behaviour moves in this +change — the engine begins stamping `'stranded'` when #13937's services half +(the operator re-arm verb) lands. A consumer that switches exhaustively over +`status` needs a `'stranded'` arm; the measured count of such switches in this +repo is zero. plugin-approvals' report-only `StrandedRunState` +(`'missing' | 'failed'`) is deliberately not promoted (same ruling). diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 1dd65998ef..b91d99f8ed 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -607,7 +607,7 @@ const result = AutomationApiErrorCode.parse(data); | **error** | `string` | optional | Error message if execution failed | | **durationMs** | `number` | optional | Execution duration in milliseconds | | **code** | `Enum<'PERMISSION_DENIED' \| 'INVALID_SIGNAL' \| 'RUN_NOT_FOUND' \| 'STORE_UNAVAILABLE' \| …>` | optional | Machine-readable failure classification, set alongside `error` when the caller must distinguish WHY it failed. A closed union - the members and their transport mappings are documented on the contract (`AutomationResult.code`, contracts/automation-service.ts). | -| **status** | `Enum<'completed' \| 'paused' \| 'failed'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed` means the run reached a terminal state. | +| **status** | `Enum<'completed' \| 'paused' \| 'failed' \| 'stranded'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed`/`stranded` means the run reached a terminal state. `stranded` is the terminally-failed-but-repairable run: a resume consumed the suspension and a downstream node threw, so the run is recorded as failed and can be re-armed only by an explicit operator verb - never by the resume route, which answers RUN_NOT_FOUND for it. | | **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it | | **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. | | **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast | diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 6cb3841caa..c341ba1ce7 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -337,10 +337,17 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext + 'their transport mappings are documented on the contract ' + '(`AutomationResult.code`, contracts/automation-service.ts).', ), - status: z.enum(['completed', 'paused', 'failed']).optional().describe( + // `stranded` is the contract half of the #13937 shape-4 ruling (#14384); + // the condition is #13909's. Mirrors `AutomationResult.status` member for + // member — the pin is `contracts/automation-result-status.pin.test.ts`. + status: z.enum(['completed', 'paused', 'failed', 'stranded']).optional().describe( 'Lifecycle status. `paused` means the run suspended at a node and can be ' - + 'continued with the resume route. Absent or `completed`/`failed` means ' - + 'the run reached a terminal state.', + + 'continued with the resume route. Absent or `completed`/`failed`/`stranded` ' + + 'means the run reached a terminal state. `stranded` is the ' + + 'terminally-failed-but-repairable run: a resume consumed the suspension ' + + 'and a downstream node threw, so the run is recorded as failed and can be ' + + 're-armed only by an explicit operator verb - never by the resume route, ' + + 'which answers RUN_NOT_FOUND for it.', ), runId: z.string().optional() .describe('Run id - set when `status` is `paused`, so callers can resume it'), diff --git a/packages/spec/src/contracts/automation-result-status.pin.test.ts b/packages/spec/src/contracts/automation-result-status.pin.test.ts new file mode 100644 index 0000000000..79594c7567 --- /dev/null +++ b/packages/spec/src/contracts/automation-result-status.pin.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14384] `AutomationResult.status` is exactly + * `'completed' | 'paused' | 'failed' | 'stranded'`, and the wire mirror + * (`TriggerFlowResponseSchema.data.status`, `api/automation-api.zod.ts`) is + * the same four — contract half of the #13937 shape-4 ruling (maintainer + * 2026-09-01), which names the terminally-failed-but-repairable run on this + * union: a resume consumed the suspension, a downstream node threw, the run is + * recorded as failed and can be re-armed only by an explicit operator verb + * (#13909's condition). The literal is `'stranded'`. + * + * Three things are pinned, because each drifts on its own: + * + * 1. **The union's membership, at the type level.** `status` is a TypeScript + * interface member, not a Zod enum, so the only thing that can assert it is + * a compile-time identity (`Eq`, the `automation-api.zod.test.ts` form — + * a widening or a narrowing on either side turns the exported alias red + * under `check:test-typecheck`, which reads this file). + * 2. **Wire ↔ contract parity, at both levels.** The Zod enum's `.options` + * are read at runtime and compared to the same list, and its inferred + * type is bound to the contract's. #13078 bound the whole `data` object; + * this pins the ONE member the ruling added so a future member added to + * one side alone fails here by name. + * 3. **The JSDoc names the condition.** The card's acceptance is a JSDoc line + * naming the condition, and prose is unassertable except by reading it: + * the contract source is read and the doc block above the union is + * required to say what `'stranded'` is. + * + * ⛔ Not pinned, deliberately: any relation to `ExecutionStatus` + * (`automation/execution.zod.ts`, the persisted run-row vocabulary) or to + * plugin-approvals' `StrandedRunState` — the ruling keeps the latter a + * plugin-local report label, and whether the run ROW ever carries this word is + * the services half's to measure (#13937). + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { TriggerFlowResponseSchema } from '../api/automation-api.zod'; +import type { TriggerFlowResponse } from '../api/automation-api.zod'; + +import type { AutomationResult } from './automation-service'; + +/** Type-level identity: true iff A and B are the same type. */ +type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +/** Compile error when the argument is not `true`. */ +type Assert< T extends true > = T; + +type ContractStatus = NonNullable; +type WireStatus = NonNullable; + +/** + * The closed union, spelled once, in declaration order. `satisfies` proves + * every literal here is a member; the `Eq` below proves there is no member + * that is not here. + */ +export const AUTOMATION_RESULT_STATUSES = [ + 'completed', + 'paused', + 'failed', + 'stranded', +] as const satisfies readonly ContractStatus[]; + +/** + * Exported deliberately — an unread alias inside a test body is TS6196, and a + * pin no program compiles is no pin at all (`check:test-typecheck` compiles + * this file under `tsconfig.test.json`). + */ +export type AutomationResultStatusIsExactlyTheFour = Assert< Eq< ContractStatus, (typeof AUTOMATION_RESULT_STATUSES)[number] > >; +/** Wire ↔ contract: the Zod enum's inferred type IS the interface's union. */ +export type WireStatusMatchesContract = Assert< Eq< WireStatus, ContractStatus > >; + +/** The wire enum, unwrapped from `.optional()` through the `lazySchema` Proxy. */ +const wireStatusEnum = TriggerFlowResponseSchema.shape.data.shape.status.unwrap(); + +describe('[#14384] AutomationResult.status names the stranded run', () => { + it('reads a non-empty membership (anti-vacuity)', () => { + expect(AUTOMATION_RESULT_STATUSES.length).toBe(4); + expect(wireStatusEnum.options.length).toBeGreaterThan(0); + }); + + it('the wire enum carries exactly the contract union, in the same order', () => { + expect([...wireStatusEnum.options]).toEqual([...AUTOMATION_RESULT_STATUSES]); + }); + + it("names the terminally-failed-but-repairable run 'stranded' (#13937 shape 4)", () => { + expect(AUTOMATION_RESULT_STATUSES).toContain('stranded'); + expect(wireStatusEnum.options).toContain('stranded'); + }); + + it('a stranded terminal envelope parses and is PRESERVED on the wire', () => { + // A strip-mode object drops undeclared keys silently — the #13078 lesson — + // so parse success alone proves nothing; the value must come back out. + const parsed = TriggerFlowResponseSchema.parse({ + success: true, + data: { + success: false, + status: 'stranded', + runId: 'run_stranded_001', + error: "node 'notify' threw after the approval was consumed", + }, + }); + expect(parsed.data.status).toBe('stranded'); + expect(parsed.data.success).toBe(false); + expect(parsed.data.runId).toBe('run_stranded_001'); + }); + + it('refuses a status outside the four, at `data.status`, as an enum violation', () => { + const result = TriggerFlowResponseSchema.safeParse({ + success: true, + data: { success: false, status: 'strand' }, + }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.path.join('.') === 'data.status'); + expect(issue).toBeDefined(); + expect(issue?.code).toBe('invalid_value'); + }); + + it('the contract JSDoc names the condition beside the literal', () => { + const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8'); + const declaration = "status?: 'completed' | 'paused' | 'failed' | 'stranded';"; + const at = source.indexOf(declaration); + expect(at).toBeGreaterThan(-1); + // The doc block immediately above the declaration — from its last `/**`. + const docStart = source.lastIndexOf('/**', at); + const doc = source.slice(docStart, at); + expect(doc).toContain("`'stranded'`"); + // The condition, in the ruling's own terms: a consumed suspension, a + // downstream throw, re-armable only by an explicit operator verb. + expect(doc).toMatch(/consumed the\s+\*?\s*suspension/i); + expect(doc).toMatch(/downstream node threw/i); + expect(doc).toMatch(/explicit operator verb/i); + // And the ruling's boundary: the plugin-local label is not promoted. + expect(doc).toContain('StrandedRunState'); + }); +}); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index bf277eb62e..85b3d15a35 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -291,9 +291,28 @@ export interface AutomationResult { * Lifecycle status. `'paused'` means the run suspended at a node (e.g. * an Approval node awaiting a human decision, ADR-0019) and can be * continued later with {@link IAutomationService.resume}. Absent or - * `'completed'`/`'failed'` ⇒ the run reached a terminal state. - */ - status?: 'completed' | 'paused' | 'failed'; + * `'completed'`/`'failed'`/`'stranded'` ⇒ the run reached a terminal + * state. + * + * `'stranded'` names the terminally-failed-but-repairable run (#13909; + * the #13937 shape-4 ruling, maintainer 2026-09-01): a resume CONSUMED the + * suspension, a downstream node threw, and the run is recorded as failed — + * terminal exactly like `'failed'`, except that the pause a durable + * decision (an approval, a screen submission) was waiting on is gone with + * it, so the run can be re-armed only by an explicit operator verb — never + * by {@link IAutomationService.resume} (which answers `'RUN_NOT_FOUND'`: + * there is no suspension left) and never automatically. Distinct from + * `'failed'` on purpose: that one says the run ran and was rejected; this + * one says a recorded continuation stopped mid-flight and an operator has + * something to repair. This member is the ruling's contract half; the + * engine begins stamping it when #13937's services half (the re-arm verb + * and the catch-arm stamp in `resumeInternal`) lands. plugin-approvals' + * `StrandedRunState` (`'missing' | 'failed'`) is a report-only label over + * a request's run and is deliberately NOT promoted to this status (same + * ruling): it classifies WHY a request's run is unrecoverable, this names + * the run's own lifecycle verdict. + */ + status?: 'completed' | 'paused' | 'failed' | 'stranded'; /** Run id — set when `status` is `'paused'`, so callers can resume it. */ runId?: string; /**