From 163700fa04f5fb60f1fc84c225ee661d1155292f Mon Sep 17 00:00:00 2001 From: os-dev Date: Mon, 7 Sep 2026 14:33:44 +0000 Subject: [PATCH 1/2] wip(spec): ResumeFailureReport carrier on the approval results (#16559) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- ...sume-failure-report-on-approval-results.md | 13 ++ .../spec/src/contracts/approval-service.ts | 138 ++++++++++++- .../resume-failure-report.pin.test.ts | 188 ++++++++++++++++++ 3 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 .changeset/resume-failure-report-on-approval-results.md create mode 100644 packages/spec/src/contracts/resume-failure-report.pin.test.ts diff --git a/.changeset/resume-failure-report-on-approval-results.md b/.changeset/resume-failure-report-on-approval-results.md new file mode 100644 index 0000000000..6e83a74c96 --- /dev/null +++ b/.changeset/resume-failure-report-on-approval-results.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +--- + +`ResumeFailureReport` — the machine-readable half of a resume failure, declared once in `contracts/approval-service.ts` and carried as the optional, additive `resumeFailure` member of `ApprovalRecallResult` and `ApprovalDecisionResult` (the contract half of the #16472 family ruling, decision batch #76). + +The ruling: when a resume failure is told to the caller, it is told in a shape the caller can act on — a registered error code, the `runId` of the run that is actually stranded, and `repairable` — and the door's status code does not change because of it. A decision whose own run advanced still answers success, with the failure behind it carried on the success answer. This change declares that shape; the doors adopt it separately (#15556 for the decision door's subflow bubble-up, #15970 for `recall`). + +- **Declared once, by reuse.** `ResumeFailureReport extends ResumeFailureDetails` (`api/automation-api.zod.ts`), the structure the automation resume door already publishes inside its `400 FLOW_FAILED` details. `runId`, `status` and `repairable` are inherited, never re-spelled, so the carriers cannot drift; a caller that parses the member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door. The report adds exactly the one member a success envelope cannot leave to its envelope: `code`. +- **No new error code is minted.** `code` is typed as `ErrorCode`, the ADR-0112 ledger vocabulary, so an unregistered spelling fails `tsc` rather than reaching the wire. A consumer that needs a distinct code to branch is its own card. +- **The absence rule is explicit and pinned.** The member is optional because it is additive, and an absent member means no report was made — a producer that predates this field, a door that never resumes — never that no run is stranded. A consumer may branch on presence to read a failure; it must not branch on absence to conclude health. +- **`resumeError` is no longer "when `resumed` is false".** Both carriers' `resumeError` docblocks now say its presence is decided by whether a failure was told, never by `resumed`, and name `resumeFailure` as the machine-readable half of the same telling; both `resumed` docblocks say `true` speaks for this door's own resume, not for every run behind it. `ApprovalSendBackResult` and `ApprovalResubmitResult` are unchanged — the ruling names no carrier on those doors. +- `StrandedDecisionDetails` (`@objectstack/types`, the error-envelope carrier of the `decide` door's own strand, #13807) is unchanged. diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index f6019f3f6b..ea350ea768 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -25,6 +25,8 @@ // (ADR-0095 D2) and `tabPermissions` — see item 3 of the module doc in // `./sharing-service.js` for the boundary and the measured consequence. import type { ExecutionContext } from '../kernel/execution-context.zod.js'; +import type { ErrorCode } from '../api/error-code-ledger.zod.js'; +import type { ResumeFailureDetails } from '../api/automation-api.zod.js'; /** * Lifecycle states of an approval request, in the order the @@ -552,6 +554,69 @@ export interface ApprovalRecallInput { comment?: string; } +/** + * The machine-readable half of a resume failure, as a SUCCESS envelope tells + * it (the #16472 family ruling — maintainer 2026-09-07, decision batch #76). + * + * The rule: when a resume failure is told to the caller, it is told in a + * shape the caller can act on — a registered error code, the `runId` of the + * run that is actually stranded, and `repairable` — and the door's status + * code does not change because of it. A decision whose own run advanced still + * answers success, with the failure behind it carried as an optional, + * additive member of that success answer: {@link ApprovalRecallResult} and + * {@link ApprovalDecisionResult} carry it as `resumeFailure`. + * + * Declared ONCE, by reuse. The members every carrier shares — `runId`, + * `status`, `repairable` — ARE {@link ResumeFailureDetails} + * (`api/automation-api.zod.ts`), the structure the automation resume door + * already publishes inside its `400 FLOW_FAILED` `error.details` (#15221, the + * ruling's third carrier). They are inherited here, never re-spelled, so the + * two cannot drift, and a caller that parses this member with + * `ResumeFailureDetailsSchema` reads the same three facts it reads off that + * door. What this adds is the one member a success envelope cannot leave to + * its envelope: on the resume door the registered code is the answer's own + * `code`; on a success answer nothing else names the failure class, so it + * rides here as {@link code}. + * + * ⛔ No new error code is minted under the ruling. `code` is typed as + * `ErrorCode` — the ADR-0112 vocabulary `ApiErrorSchema.code` parses + * against — so an unregistered spelling fails `tsc` instead of reaching the + * wire; a consumer that needs a distinct code to branch is its own card, and + * a new code is a ledger event (#16404). + * + * ⛔ Not `StrandedDecisionDetails` (`@objectstack/types`): that is the + * ERROR-envelope carrier of the `decide` door's OWN strand (#13807 — the + * thrown `RESUME_FAILED` whose `finalized` / `decision` say the decision + * stands), and it stays exactly as it is. This structure is what a success + * answer carries, and `finalized` / `decision` are already top-level members + * of the results that carry it. The two share the `runId` / `repairable` + * vocabulary through the spec declaration, on purpose. + * + * Two halves of ONE telling. The prose `resumeError` beside this member is + * the human-readable half; this is the machine-readable half. A producer + * that tells one tells both, about the same event — a strand, a lost run, a + * composition with no engine to advance the run, or a tolerated concurrent + * duplicate (`RESUME_IN_PROGRESS`, `repairable: false`) — so a caller + * branches on {@link code} and {@link repairable}, never on the text. + * + * The absence rule, and it is load-bearing: the member is optional because + * it is ADDITIVE, and an absent member means no report was made — a producer + * that predates this field, a door that never resumes — never that no run is + * stranded. A consumer may branch on presence to READ a failure; it must not + * branch on absence to CONCLUDE health. Reading an absent discriminator as a + * negative is #15555's false-negative class, the misreading #15358 measured. + */ +export interface ResumeFailureReport extends ResumeFailureDetails { + /** + * The registered error code the failure is told under (ADR-0112 ledger): + * `RESUME_FAILED` for a run that could not be advanced, + * `RESUME_TARGET_LOST` when the run behind the request no longer exists, + * `RESUME_IN_PROGRESS` for the tolerated duplicate. Required here, on + * purpose: a success answer has no envelope `code` to fall back on. + */ + code: ErrorCode; +} + /** Result of a recall. */ export interface ApprovalRecallResult { request: ApprovalRequestRow; @@ -562,14 +627,38 @@ export interface ApprovalRecallResult { * `output.decision = 'recall'`) so it doesn't stay suspended forever. The * engine has no run-cancel primitive yet; the reject edge is the closest * "did not pass" semantics. + * + * `true` says THIS door's own resume completed. It does not say every run + * behind it advanced: a resume that completed and then stranded a run + * further up (a subflow's parent, #15556) still answers `true`, with the + * strand told on {@link resumeFailure}. */ resumed?: boolean; /** - * Why the run was not resumed, when `resumed` is false but the recall itself - * succeeded. A recall abandons the request, so a lost run does not fail the - * call — but it must not read as a clean resume either (#4420). + * Why a run was not resumed, in prose — the human-readable half. A recall + * abandons the request, so a lost run does not fail the call — but it must + * not read as a clean resume either (#4420). + * + * ⛔ Not gated on `resumed` being false. Since the #16472 ruling a resume + * failure can ride a `resumed: true` answer (the run this door resumed + * advanced; one behind it did not), so presence is decided by whether a + * failure was TOLD, never by `resumed`. The machine-readable half of the + * same telling is {@link resumeFailure}; a caller branches on that, never + * on this text. */ resumeError?: string; + /** + * The machine-readable half of a resume failure this recall tolerated + * (#15970; the #16472 ruling): the registered code, the `runId` of the run + * that is actually stranded — which need not be {@link runId}, the run this + * request gated — and whether the engine says it is repairable. + * + * Optional and ADDITIVE. ⚠️ An absent member means no report was made, + * never that no run is stranded: a producer that predates this field + * answers exactly what it always did, so absence is not a reading of + * health. Presence is the signal; absence is not its negation. + */ + resumeFailure?: ResumeFailureReport; } /** Input for sending a pending request back for revision (ADR-0044). */ @@ -634,19 +723,46 @@ export interface ApprovalDecisionResult { /** * True when the owning flow run was resumed as a result of this decision. * - * A decision that finalises a flow-bound request and CANNOT resume its run - * throws rather than returning `resumed: false` — a recorded decision whose - * flow never advances is the zombie half-state of #4420. `false` here means - * either there was nothing to resume (no run, not finalised, no automation - * attached) or a benign duplicate, in which case see {@link resumeError}. + * A decision that finalises a flow-bound request and CANNOT resume its OWN + * run throws rather than returning `resumed: false` — a recorded decision + * whose flow never advances is the zombie half-state of #4420. `false` here + * means either there was nothing to resume (no run, not finalised, no + * automation attached) or a benign duplicate, in which case see + * {@link resumeError} and {@link resumeFailure}. + * + * `true` says this door's own resume completed. It does not say every run + * behind it advanced: a decision inside a subflow whose child resumed and + * whose parent then stranded (#15556) still answers `true`, and the + * parent's strand is told on {@link resumeFailure} — carried on the + * success answer per the #16472 ruling, never thrown. */ resumed?: boolean; /** - * Why the run was not resumed, on the one path that tolerates it: a - * concurrent duplicate resume (`RESUME_IN_PROGRESS`) — the other caller is - * already advancing the run, so this decision is complete and correct. + * Why a run was not resumed, in prose — the human-readable half. + * + * Before the #16472 ruling this was set on exactly one path, the tolerated + * concurrent duplicate (`RESUME_IN_PROGRESS` — the other caller is already + * advancing the run, so this decision is complete and correct), and only + * beside `resumed: false`. ⛔ Not gated on `resumed` being false any more: + * a failure behind a `resumed: true` answer is told here too, so presence + * is decided by whether a failure was TOLD, never by `resumed`. The + * machine-readable half of the same telling is {@link resumeFailure}; a + * caller branches on that, never on this text. */ resumeError?: string; + /** + * The machine-readable half of a resume failure told on this success + * answer (#15556; the #16472 ruling): the registered code, the `runId` of + * the run that is actually stranded — the PARENT's when the strand is a + * subflow's bubble-up, never the healthy child's that {@link runId} names — + * and whether the engine says it is repairable. + * + * Optional and ADDITIVE. ⚠️ An absent member means no report was made, + * never that no run is stranded: a producer that predates this field + * answers exactly what it always did, so absence is not a reading of + * health. Presence is the signal; absence is not its negation. + */ + resumeFailure?: ResumeFailureReport; } /** diff --git a/packages/spec/src/contracts/resume-failure-report.pin.test.ts b/packages/spec/src/contracts/resume-failure-report.pin.test.ts new file mode 100644 index 0000000000..1c285f446a --- /dev/null +++ b/packages/spec/src/contracts/resume-failure-report.pin.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16559] The machine-readable half of a resume failure is declared ONCE in + * `packages/spec` and reused by the approval carriers — the contract half of + * the #16472 family ruling (maintainer 2026-09-07, decision batch #76). + * + * The ruling: a resume failure told to the caller is told as a registered + * error code, the `runId` of the run that is actually stranded, and + * `repairable`; the door's status code does not move, so a success answer + * carries the failure behind it as an optional, additive member. The + * structure is `ResumeFailureReport` (`contracts/approval-service.ts`), and + * it is NOT a second declaration: its shared members are inherited from + * `ResumeFailureDetails` (`api/automation-api.zod.ts`, the automation resume + * door's `400 FLOW_FAILED` details — the ruling's third carrier, #15221), and + * it adds exactly the one member a success envelope cannot leave to its + * envelope, the code. + * + * Four things are pinned, because each drifts on its own: + * + * 1. **Declared once.** `ResumeFailureReport` minus `code` IS + * `ResumeFailureDetails` — a type-level identity (`Eq`, the + * `automation-result-status.pin.test.ts` form), so a member added to or + * dropped from either side reds this file by name under + * `check:test-typecheck`, which compiles it. And at runtime the wire + * schema PARSES a report and hands the three shared members back out — + * the "reused by the carriers" claim, measured rather than asserted. + * 2. **Both approval carriers carry it, optionally.** `ApprovalRecallResult` + * and `ApprovalDecisionResult` declare `resumeFailure?: ResumeFailureReport` + * — the exact optional shape, so a carrier that makes it required (which + * would make every pre-ruling producer a type error) or drops it reds here. + * 3. **No new code is minted.** `code` is `ErrorCode`, the ADR-0112 + * vocabulary, and it is REQUIRED: an unregistered spelling is refused at + * compile time (`@ts-expect-error`, real because this file is compiled), + * and the codes the docblock names as examples are registered in the + * ledger today, so the prose cannot outlive the ledger. + * 4. **The absence rule and the retired predicate, in the prose.** The + * card's acceptance is a docblock that makes the absence rule explicit + * ("an absent member must never be readable as not stranded") and a + * `resumeError` docblock that no longer asserts "when `resumed` is + * false" — batch #76 made `resumed: true` plus a carried failure a legal + * shape. Prose is unassertable except by reading it, so the contract + * source is read: both `resumeFailure` docblocks must carry the absence + * sentence, both widened `resumeError` docblocks must say presence is + * decided by the telling and not by `resumed`, and the retired predicate + * must be gone from the file. + * + * ⛔ Not pinned, deliberately: anything about `StrandedDecisionDetails` + * (`@objectstack/types`, the ERROR-envelope carrier of #13807) — a different + * package, a different envelope, and the ruling leaves it as it is — and + * anything a producer does: no producer lives in spec, and the three + * consumer cards (#15556, #15970, #15221) each keep their own pins. + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { ResumeFailureDetailsSchema } from '../api/automation-api.zod'; +import type { ResumeFailureDetails } from '../api/automation-api.zod'; +import { REGISTERED_ERROR_CODES } from '../api/error-code-ledger.zod'; +import type { ErrorCode } from '../api/error-code-ledger.zod'; + +import type { + ApprovalDecisionResult, + ApprovalRecallResult, + ResumeFailureReport, +} from './approval-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; + +/** + * 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`). + */ +/** 1. Declared once: the report minus its code IS the wire schema's input type. */ +export type ReportSharesTheWireMembers = Assert< Eq< Omit, ResumeFailureDetails > >; +/** 3. The code member is the ledger vocabulary, exactly. */ +export type CodeIsTheLedgerVocabulary = Assert< Eq< ResumeFailureReport['code'], ErrorCode > >; +/** 3. …and it is required — `undefined` is not a member of its type. */ +export type CodeIsRequired = Assert< Eq< undefined extends ResumeFailureReport['code'] ? true : false, false > >; +/** 2. Both carriers declare the member at the exact optional shape. */ +export type RecallCarriesTheReport = Assert< Eq< ApprovalRecallResult['resumeFailure'], ResumeFailureReport | undefined > >; +export type DecisionCarriesTheReport = Assert< Eq< ApprovalDecisionResult['resumeFailure'], ResumeFailureReport | undefined > >; + +/** The codes the report's docblock names as examples; their registration is measured below. */ +const DOCUMENTED_EXAMPLE_CODES = ['RESUME_FAILED', 'RESUME_TARGET_LOST', 'RESUME_IN_PROGRESS'] as const satisfies readonly ErrorCode[]; + +/** A complete report, the shape a consumer card produces on a success answer. */ +const strandedParent: ResumeFailureReport = { + code: 'RESUME_FAILED', + runId: 'run_parent_001', + status: 'stranded', + repairable: true, +}; + +/** 3. An unregistered code is refused at compile time — no new code is minted here. */ +export const unmintedCodeIsRefused: ResumeFailureReport = { + // @ts-expect-error — `FLOW_STRANDED` is not in the ADR-0112 ledger; the ruling forbids minting it here. + code: 'FLOW_STRANDED', + runId: 'run_parent_001', + repairable: true, +}; + +const CONTRACT_SOURCE = readFileSync( + fileURLToPath(new URL('./approval-service.ts', import.meta.url)), + 'utf8', +); + +/** The docblock of one optional member, by member name, from the contract source. */ +function docblockOf(iface: string, member: string): string { + const start = CONTRACT_SOURCE.indexOf(`export interface ${iface} {`); + expect(start, `interface ${iface} is declared`).toBeGreaterThanOrEqual(0); + const end = CONTRACT_SOURCE.indexOf('\n}\n', start); + const body = CONTRACT_SOURCE.slice(start, end); + const memberAt = body.indexOf(`\n ${member}?:`); + expect(memberAt, `${iface}.${member} is declared optional`).toBeGreaterThanOrEqual(0); + const docStart = body.lastIndexOf('/**', memberAt); + expect(docStart, `${iface}.${member} carries a docblock`).toBeGreaterThanOrEqual(0); + return body.slice(docStart, memberAt); +} + +describe('[#16559] ResumeFailureReport — the resume failure a success answer carries (batch #76)', () => { + it('1. the wire schema parses a report and hands the three shared members back out (declared once, measured)', () => { + // A strip-mode object drops undeclared keys silently and would parse + // anything — so parse success alone proves nothing; the shared values + // must come back out, and `code` (the member this structure ADDS) must + // be the only thing the wire schema does not know. + const parsed = ResumeFailureDetailsSchema.parse(strandedParent); + expect(parsed).toEqual({ runId: 'run_parent_001', status: 'stranded', repairable: true }); + expect(Object.keys(strandedParent).sort()).toEqual([...Object.keys(parsed), 'code'].sort()); + }); + + it('1. the wire schema keeps refusing what the report refuses — repairable is required, status is the two terminal failures', () => { + // Anti-vacuity for the identity above: the inherited members carry the + // wire schema's constraints, not merely its names. + expect(ResumeFailureDetailsSchema.safeParse({ code: 'RESUME_FAILED', runId: 'run_1' }).success).toBe(false); + expect(ResumeFailureDetailsSchema.safeParse({ code: 'RESUME_FAILED', runId: 'run_1', status: 'paused', repairable: false }).success).toBe(false); + expect(ResumeFailureDetailsSchema.safeParse({ code: 'RESUME_TARGET_LOST', runId: 'run_1', repairable: false }).success).toBe(true); + }); + + it('3. every code the docblock names as an example is registered in the ledger today', () => { + expect(DOCUMENTED_EXAMPLE_CODES.length).toBeGreaterThan(0); + for (const code of DOCUMENTED_EXAMPLE_CODES) { + expect(REGISTERED_ERROR_CODES, `${code} is a registered code`).toContain(code); + } + // The compile-time refusal above is the real pin; this is its runtime + // shadow, so a ledger that gains `FLOW_STRANDED` is noticed here by name. + expect(REGISTERED_ERROR_CODES).not.toContain('FLOW_STRANDED'); + }); + + it('4. both `resumeFailure` docblocks make the absence rule explicit', () => { + for (const iface of ['ApprovalRecallResult', 'ApprovalDecisionResult']) { + const doc = docblockOf(iface, 'resumeFailure'); + expect(doc, `${iface}.resumeFailure states that absence is not a reading`) + .toContain('An absent member means no report was made'); + expect(doc, `${iface}.resumeFailure states what absence must never mean`) + .toContain('never that no run is stranded'); + } + }); + + it('4. both widened `resumeError` docblocks say presence is decided by the telling, not by `resumed`', () => { + for (const iface of ['ApprovalRecallResult', 'ApprovalDecisionResult']) { + const doc = docblockOf(iface, 'resumeError'); + expect(doc, `${iface}.resumeError is no longer gated on resumed`) + .toContain('never by `resumed`'); + expect(doc, `${iface}.resumeError names its machine-readable half`) + .toContain('{@link resumeFailure}'); + } + }); + + it('4. the retired predicate is gone from the contract file', () => { + // Batch #76 made `resumed: true` plus a carried failure a legal shape, so + // the sentence "when `resumed` is false" is false wherever it appears. + // The search is over the FILE, not the four sites the card listed — the + // card's own warning (seat #6021 correction 85). + expect(CONTRACT_SOURCE).not.toMatch(/when `resumed` is false/); + expect(CONTRACT_SOURCE).not.toMatch(/when resumed is false/); + // Anti-vacuity: the file still declares the members the predicate was about. + expect(CONTRACT_SOURCE.match(/\n resumeError\?: string;/g)?.length).toBe(4); + expect(CONTRACT_SOURCE.match(/\n resumeFailure\?: ResumeFailureReport;/g)?.length).toBe(2); + }); +}); From 44eb05bf562b4d35a2d492054b0e4a35fac5c720 Mon Sep 17 00:00:00 2001 From: os-dev Date: Mon, 7 Sep 2026 14:41:01 +0000 Subject: [PATCH 2/2] chore(spec): regenerate api-surface and export-origins for ResumeFailureReport (#16559) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- packages/spec/api-surface/contracts.json | 1 + packages/spec/export-origins/contracts.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 431f40ea87..cfe48c185c 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -249,6 +249,7 @@ "ReportSchedule (interface)", "ResolveDependenciesInput (interface)", "ResolveShareLinkResult (interface)", + "ResumeFailureReport (interface)", "ResumeSignal (interface)", "RlsMembershipContext (interface)", "RollbackInput (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 87265e5bed..e27f13eaab 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -249,6 +249,7 @@ "ReportSchedule": "src/contracts/report-service.ts#ReportSchedule (interface)", "ResolveDependenciesInput": "src/contracts/package-service.ts#ResolveDependenciesInput (interface)", "ResolveShareLinkResult": "src/contracts/share-link-service.ts#ResolveShareLinkResult (interface)", + "ResumeFailureReport": "src/contracts/approval-service.ts#ResumeFailureReport (interface)", "ResumeSignal": "src/contracts/automation-service.ts#ResumeSignal (interface)", "RlsMembershipContext": "src/contracts/rls-membership-resolver.ts#RlsMembershipContext (interface)", "RollbackInput": "src/contracts/package-service.ts#RollbackInput (interface)",