From 5d2891b2d8d6b06857e9e6ed4ea4a6d91c0457df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 11:43:09 +0000 Subject: [PATCH] docs(spec): a durable pause ends the retry-governed segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `errorHandling.strategy: 'retry'` describes ONE synchronous dispatch. A run that parks on an `approval`, `screen` or `wait` node (ADR-0019) resumes as a new segment outside it, so anything that fails after the pause gets exactly one attempt. That was measured on the engine but stated nowhere an author reads. Ruled deliberate on #9705 (Option A, maintainer ruling 2026-08-18). Prose only: - `flow.zod.ts` — the boundary and the authoring consequence on the `errorHandling` block's `describe()` and on `strategy`'s, with the rationale in the block JSDoc; the generated reference projections follow. - `content/docs/automation/flows.mdx` — a normative subsection with the two-halves table, why the boundary is the contract rather than a gap, and the recipe for protecting the post-pause half (a `try_catch` node's own `retry`, or a `fault` edge); plus a pointer from the durable-pause section. - `content/docs/automation/approvals.mdx` — the same pointer where an author meets it, at the step the run resumes. - `retry-attempt-pause.test.ts` — the #9510 pin retitled from measured current behaviour to the RULED contract. Comment and test title only; every assertion is byte-identical. No validation change: the accepted flow set is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD --- .changeset/pause-ends-retry-segment.md | 20 ++++++ content/docs/automation/approvals.mdx | 12 ++++ content/docs/automation/flows.mdx | 69 +++++++++++++++++++ .../docs/references/api/automation-api.mdx | 2 +- content/docs/references/automation/flow.mdx | 2 +- .../src/retry-attempt-pause.test.ts | 35 +++++++--- packages/spec/src/automation/flow.zod.ts | 27 +++++++- 7 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 .changeset/pause-ends-retry-segment.md diff --git a/.changeset/pause-ends-retry-segment.md b/.changeset/pause-ends-retry-segment.md new file mode 100644 index 0000000000..1486b04e12 --- /dev/null +++ b/.changeset/pause-ends-retry-segment.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': patch +--- + +Document the retry/durable-pause boundary on a flow's `errorHandling` block: a durable +pause (`approval`, `screen`, `wait` — ADR-0019) **ends the retry-governed segment**. +`errorHandling.strategy: 'retry'` describes one synchronous dispatch, so a run that pauses +and later resumes gets exactly one attempt for anything that fails after the pause. + +Prose only — no validation change. The accepted flow set is unchanged and every flow that +parsed before parses identically; what changes is that the boundary is now stated where an +author meets it (the `errorHandling` and `strategy` `describe()` text, which is what the +generated reference tables render) instead of having to be inferred from engine behaviour. + +The boundary is deliberate rather than a gap: the retry knobs (`backoffMs`, +`backoffMultiplier`, `jitter`) model an in-process loop, which a pause of arbitrary +duration is not, and the durable continuation carries no attempt counter. To protect the +half of a flow that runs after a pause, give that half its own failure handling in the +flow — a `try_catch` node with its own `retry` around the post-resume work, or a `fault` +edge to a handler node. `content/docs/automation/flows.mdx` carries the recipe. diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 8dc18227b2..ffe7a2763f 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -459,6 +459,18 @@ what `sys_approval_request.status` offers and what `ApprovalStatus` is derived from. If you add a status there, this line is a fourth copy that nothing updates for you. + + **The resumed half is not covered by the flow's `errorHandling.retry`.** A + durable pause ends the retry-governed segment, so `strategy: 'retry'` on the + flow protects the work *before* the approval and gives whatever fails after the + decision exactly one attempt. Wrap the post-decision work in a `try_catch` node + with its own `retry` (or draw a `fault` edge to a handler) — see [A durable + pause ends the retry-governed + segment](/docs/automation/flows#retry-pause-boundary). It is also the shape you + want regardless: a flow-level retry re-runs the flow **from the start**, which + would open a second approval request. + + ### Beyond approve/reject — the full decision surface diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 09a71bc70e..3ae6dba6de 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -490,6 +490,14 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume | `wait` (timer) | an ISO-8601 duration elapses | **automatically** — a one-shot job resumes the run; after a cold boot the engine re-arms pending timers from the durable store (overdue timers resume immediately) | | `wait` (signal) | a named external event | any caller invoking `resume(runId)` | + + **A pause ends the flow's retry-governed segment.** `errorHandling: { strategy: + 'retry' }` covers one synchronous dispatch, so a failure *after* the run + resumes is not retried — the post-pause half needs its own failure handling in + the flow. See [A durable pause ends the retry-governed + segment](#retry-pause-boundary). + + ### Who may resume — the gate is the suspended node The resume route is generic, so **the node the run is parked on** decides @@ -1159,6 +1167,67 @@ is fine and simply ignored. ambiguous is now rejected instead of guessed. +### A durable pause ends the retry-governed segment [#retry-pause-boundary] + +`strategy: 'retry'` governs **one synchronous dispatch**. When a run parks on a +node that pauses durably — `approval`, `screen`, `wait` +([ADR-0019](#durable-pause--resume-adr-0019)) — the pause **ends the +retry-governed segment**. The continuation that resumes later, possibly days +later and in a different process, is a new segment outside it: whatever fails +after the resume gets exactly **one** attempt. + +So in the ordinary shape — a flaky callout, then an approval, then the work that +follows the decision — the two halves of the flow are not protected alike: + +| Where the failure happens | Under `errorHandling: { strategy: 'retry', maxRetries: 3 }` | +| :--- | :--- | +| before the run pauses | retried — the whole flow re-runs, up to 3 more times | +| after the run resumes | **not retried** — one attempt, then the run fails | + + + This is the contract, not a gap waiting to be filled. The retry knobs describe + an **in-process loop**: `backoffMs` and `backoffMultiplier` are delays the + engine sleeps through and `jitter` spreads a thundering herd — none of which a + pause of arbitrary duration can honestly extend across, and the persisted + continuation carries no attempt counter for the same reason. Having the resume + inherit the remaining budget is a recorded revisit path, not a planned change; + it would need a new field on the durable snapshot and an answer for a flow + republished mid-pause with a different `maxRetries`. + + +#### Protecting the half that runs after the pause + +Flow-level `retry` cannot reach that half, so give it its own failure handling +**inside the flow** — both mechanisms already documented above work after a +resume, because both are per-node and local to the segment that is running: + +- a **`try_catch` node** wrapping the post-resume work, with its own `retry` + block — the direct replacement for the flow-level budget, and the one to reach + for when the post-decision step is a flaky callout; +- a **`fault` edge** from the post-resume node to a handler node, when one + failure has a specific recovery rather than a re-run. + +```typescript +// approve branch: the post-decision callout carries its own retry, so it is +// protected whether the run reached it on attempt 1 or after a two-day approval. +{ + id: 'post_approval', + type: 'try_catch', + label: 'Provision after approval', + config: { + try: { nodes: [{ id: 'provision', type: 'http', label: 'Provision', config: { /* … */ } }], edges: [] }, + catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] }, + errorVariable: '$error', + retry: { maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2 }, + }, +} +``` + +Note this is not a workaround for the boundary — it is the better shape anyway: +flow-level `retry` replays the flow **from the start**, which after an approval +would mean opening a second approval request. A `try_catch` retries only the +region that failed. + ## Discovery & Registration You almost never call `engine.registerFlow()` directly. The diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 1a1bdfbaa7..3726e211b1 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -99,7 +99,7 @@ const result = AutomationApiErrorCode.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | -| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration | +| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index e6e2b34aa1..f2a9f502e1 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -53,7 +53,7 @@ const result = FlowSchema.parse(data); | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | -| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration | +| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/packages/services/service-automation/src/retry-attempt-pause.test.ts b/packages/services/service-automation/src/retry-attempt-pause.test.ts index 4ad629c8ee..d7e37f7f99 100644 --- a/packages/services/service-automation/src/retry-attempt-pause.test.ts +++ b/packages/services/service-automation/src/retry-attempt-pause.test.ts @@ -329,10 +329,26 @@ describe('#9510 — a pause on a RETRY attempt is durable, not a burned attempt' }); /** - * ⭐ The retry-budget question the ruling required to be ANSWERED rather - * than assumed, measured rather than reasoned: + * ⭐ THE RULED CONTRACT — not merely "current behaviour" (#9705). * - * **A resumed run gets NO retries — on either route.** + * **A durable pause ENDS the retry-governed segment: a resumed run gets + * NO retries — on either route.** + * + * Maintainer ruling recorded 2026-08-18 on #9705 (Option A): + * `errorHandling.strategy: 'retry'` describes ONE synchronous dispatch, and + * a resumed run is a new segment outside it. So the assertions below are a + * CONTRACT PIN, not a snapshot of an accident — **changing them is a + * contract change** and needs its own ruling, not a test fix. The boundary + * is stated for authors on the `errorHandling` block's `describe()` in + * `packages/spec/src/automation/flow.zod.ts` and in + * `content/docs/automation/flows.mdx` ("A durable pause ends the + * retry-governed segment"), with the authoring recipe for protecting the + * post-pause half. Option B (resume inherits the remaining budget) is the + * recorded revisit path only — it needs a `SuspendedRun` field, a store + * migration and a re-published-flow answer, none of which is bought today. + * + * The question was required to be ANSWERED rather than assumed, and it was + * measured rather than reasoned: * * Two independent facts produce that answer, both read off `origin/main`: * @@ -348,13 +364,14 @@ describe('#9510 — a pause on a RETRY attempt is durable, not a burned attempt' * retry-path pause inherits the same answer the execute-path pause has * always had, and the two stay consistent. * - * That the resume path ignores a flow's declared retry policy is a real - * gap, but a DIFFERENT one, on a different method; it is filed as #9705 - * rather than absorbed here. What is pinned below is today's measured - * answer, so whatever that card decides is a deliberate change and not an - * accident. + * That the resume path ignores a flow's declared retry policy was filed as + * a separate card (#9705) rather than absorbed here, and that card is where + * the ruling above landed: the behaviour pinned below is the intended + * contract, and the retry knobs (`backoffMs`, `backoffMultiplier`, + * `jitter`) model an in-process loop that a pause of arbitrary duration + * cannot honestly extend across. */ - it('answers the retry-budget question: a resumed run does not retry, on either route', async () => { + it('pins the RULED contract — a durable pause ends the retry-governed segment: a resumed run does not retry, on either route', async () => { for (const failFirstAttempts of [0, 1]) { const h = bootFlow({ failFirstAttempts, afterFails: true, maxRetries: 2 }); diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 8c03292995..3d6bfa3e53 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -717,6 +717,29 @@ export const FlowSchema = lazySchema(() => strictObject( * The retry knobs are read **only** under `strategy: 'retry'`; `'fail'` and * `'continue'` ignore them (a fully spelled-out block under `'fail'` is * common and stays legal). + * + * **A durable pause ENDS the retry-governed segment** — ruled deliberate, + * not an omission. `strategy: 'retry'` describes ONE synchronous dispatch; + * when a run parks on an `approval`, `screen` or `wait` node (ADR-0019) the + * continuation is a new segment outside it, and a failure after the resume + * gets exactly one attempt. Measured, both directions: `SuspendedRun` + * carries no attempt state (nothing to inherit across the pause) and the + * resume path never consults this block (nothing would read it if it did). + * The alternative — resume inherits the remaining budget — is the recorded + * revisit path, not the contract: it would need a field on the persisted + * snapshot plus an answer for a flow republished mid-pause with a different + * `maxRetries`, and no deployment has asked for it. It is also what the + * knobs can honestly promise: `backoffMs`/`backoffMultiplier`/`jitter` + * model an in-process loop, which a pause of arbitrary duration is not. + * + * The authoring consequence is the part that belongs to the author, so it + * is stated on the block's own `.describe()` too: to protect the half of a + * flow that runs AFTER the pause, give that half its own failure handling + * in the flow — a `try_catch` node with its own `retry` around the + * post-resume work, or per-node `fault` edges to a handler. The pin for + * this behaviour is + * `packages/services/service-automation/src/retry-attempt-pause.test.ts`; + * changing it is a contract change, not a test fix. */ errorHandling: strictObject({ surface: "this flow's `errorHandling` block", @@ -766,7 +789,7 @@ export const FlowSchema = lazySchema(() => strictObject( 'Since #4964 the retry keys are the converged `RetryPolicySchema` contract, so a spelling ' + 'learned on `job.retryPolicy` or a `try_catch` node\'s `retry` is correct here too.', }, { - strategy: z.enum(['fail', 'retry', 'continue']).default('fail').describe('How to handle node execution errors'), + strategy: z.enum(['fail', 'retry', 'continue']).default('fail').describe("How to handle node execution errors. 'retry' governs ONE synchronous dispatch: a durable pause (approval/screen/wait) ends the retry-governed segment, so a failure after the run resumes is not retried."), // ── The retry policy itself: ONE declaration (#4964) ──────────────── // `maxRetries` / `backoffMs` / `backoffMultiplier` / `maxRetryDelayMs` / @@ -825,7 +848,7 @@ export const FlowSchema = lazySchema(() => strictObject( 'Note a retry re-runs the WHOLE flow, so side-effecting nodes run again.', }); } - }).optional().describe('Flow-level error handling configuration'), + }).optional().describe("Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node."), /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here; the loader translates it