Skip to content

Commit db34db0

Browse files
committed
fix(spec,service-automation)!: errorHandling.maxRetries has one default, and strategy: 'retry' states its count (#4247)
`flow.errorHandling.maxRetries` was declared twice with different values: `FlowSchema` said `.default(0)`, while the engine's `retryExecution` read `errorHandling.maxRetries ?? 3`. `??` fires only on `undefined`, so the winner was decided by the ROUTE a flow took into the engine, not by what its author wrote — 0 retries for a flow parsed by the schema, 3 for a definition built by hand and handed to the engine. The neighbouring `retryDelayMs ?? 1000` / `backoffMultiplier ?? 1` agreed with their `.default()`s; only `maxRetries` disagreed. The engine now keeps no defaults of its own: `retryExecution` takes the parsed `NonNullable<FlowParsed['errorHandling']>` and destructures all five knobs, no `??`. That is safe because `AutomationEngine.flows` only ever holds `FlowSchema.parse` output (`registerFlow` parses; the version-history rollback re-seats an already-parsed snapshot), and the parsed parameter type is what keeps a second set of defaults from growing back — a knob the spec stops defaulting becomes a compile error rather than a silent engine-side guess (Prime Directive #12). BREAKING: `strategy: 'retry'` now requires `maxRetries` >= 1. With the engine's copy gone an unstated count is unambiguously 0, and 'retry' with 0 attempts runs the flow once and stops — `strategy: 'fail'` under another label, a declared capability the runtime does not deliver. Rather than pick 0 or 3 for the author, the schema refuses the combination in both spellings (omitted → defaulted 0, and an explicit 0) with the prescription in the message; a retry re-runs the WHOLE flow, side effects included, so the count is the author's to state. `maxRetries: 0` stays legal under 'fail'/'continue', which never read it. The migration chain surfaces this as the semantic TODO `flow-retry-max-retries-required` — there is no lossless rewrite, so `os migrate meta` delegates the choice instead of guessing it. Closes #4247 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SNpSdWvbikA9PiwXzkAH4i
1 parent f1f40b4 commit db34db0

12 files changed

Lines changed: 458 additions & 22 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/service-automation": major
4+
---
5+
6+
fix(spec,service-automation)!: `errorHandling.maxRetries` has one default, and `strategy: 'retry'` states its count (#4247)
7+
8+
`flow.errorHandling.maxRetries` was declared twice, with different values:
9+
10+
- **spec**`FlowSchema` (`automation/flow.zod.ts`): `.default(0)`
11+
- **engine**`retryExecution` (`service-automation/src/engine.ts`):
12+
`errorHandling.maxRetries ?? 3`
13+
14+
`??` fires only on `undefined`, so the winner was decided by the ROUTE a flow
15+
took into the engine, not by what its author wrote:
16+
17+
| Path | `errorHandling.maxRetries` | Retries |
18+
|:---|:---|---:|
19+
| parsed by `FlowSchema` (`.default(0)` fills it) | `0` | **0** |
20+
| object built by hand and fed to the engine | `undefined` | **3** |
21+
22+
One authored intent — "I didn't write a count" — two behaviors. The neighbouring
23+
`retryDelayMs ?? 1000` / `backoffMultiplier ?? 1` agreed with their `.default()`s;
24+
only `maxRetries` disagreed, which reads as a schema default changed from 3 to 0
25+
without the engine following, not as a deliberate two-track design.
26+
27+
**The engine keeps no defaults of its own.** `retryExecution` now takes the
28+
parsed `NonNullable<FlowParsed['errorHandling']>` and destructures all five
29+
knobs — no `??`. This is safe because `AutomationEngine.flows` only ever holds
30+
`FlowSchema.parse` output (`registerFlow` parses; the version-history rollback
31+
re-seats an already-parsed snapshot), and it is what keeps a second set of
32+
defaults from growing back: a knob the spec stops defaulting becomes a compile
33+
error rather than a silent engine-side guess. Per Prime Directive #12 the spec
34+
is the one contract; a consumer-side fallback is a second de-facto one.
35+
36+
**BREAKING — `strategy: 'retry'` now requires `maxRetries` >= 1.** With the
37+
engine's copy gone, an unstated count is unambiguously `0`, and `'retry'` with 0
38+
attempts runs the flow once and stops — i.e. `strategy: 'fail'` wearing another
39+
label, a declared capability the runtime does not deliver (Prime Directive #10
40+
corollary). Rather than pick 0 or 3 on the author's behalf, `FlowSchema` refuses
41+
the combination in both spellings (omitted → defaulted 0, and an explicit 0),
42+
with the prescription in the message. A retry re-runs the **whole flow from the
43+
start** — records created again, callouts fired again — which is not a number to
44+
guess for someone.
45+
46+
FROM → TO:
47+
48+
- `errorHandling: { strategy: 'retry' }``errorHandling: { strategy: 'retry', maxRetries: 3 }`
49+
(or `strategy: 'fail'` if no retry was intended — that is what it did).
50+
- `errorHandling: { strategy: 'retry', maxRetries: 0 }` → same choice, spelled out.
51+
52+
Unaffected: `maxRetries: 0` under `strategy: 'fail'` / `'continue'` (neither
53+
reads it, and a fully spelled-out block stays legal), flows with no
54+
`errorHandling` at all, and every flow that already states a count — including
55+
the `try_catch` node's own `config.retry`, which is a separate per-region policy
56+
(`control-flow.zod.ts`) and is unchanged.

content/docs/automation/flows.mdx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ The two recovery mechanisms operate at different scopes and do not compound:
665665
| | Scope | On failure |
666666
| :--- | :--- | :--- |
667667
| `fault` edge | one node | traversal continues from the handler; the run completes |
668-
| `errorHandling: { strategy: 'retry' }` | the whole flow | the flow re-runs **from the start** |
668+
| `errorHandling: { strategy: 'retry', maxRetries: n }` | the whole flow | the flow re-runs **from the start**, up to `n` more times |
669669

670670
A failure a fault edge handled is not a flow failure, so it does **not** consume
671671
a retry. That matters because flow-level retry replays every node that already
@@ -707,13 +707,46 @@ errorHandling: {
707707

708708
| Property | Type | Description |
709709
| :--- | :--- | :--- |
710-
| `strategy` | `enum` | `'fail'` (stop) or `'retry'` (re-run the whole flow). `'continue'` parses but the engine branches only on `'retry'`, so it behaves exactly like `'fail'` — use a `fault` edge to keep going past a failed node |
711-
| `maxRetries` | `number` | Maximum retry attempts (0-10) |
712-
| `retryDelayMs` | `number` | Delay between retries (ms) |
710+
| `strategy` | `enum` | `'fail'` (stop) or `'retry'` (re-run the whole flow). `'continue'` parses but the engine branches only on `'retry'`, so it behaves exactly like `'fail'` — use a `fault` edge to keep going past a failed node (default `'fail'`) |
711+
| `maxRetries` | `number` | Retry attempts **after** the initial one, `0``10`. Under `strategy: 'retry'` it must be at least `1` and there is no default — see below (default `0`, i.e. no retries, for the strategies that never retry) |
712+
| `retryDelayMs` | `number` | Delay between retries (ms) (default `1000`) |
713713
| `backoffMultiplier` | `number` | Exponential backoff multiplier (default `1`) |
714714
| `maxRetryDelayMs` | `number` | Ceiling on the backed-off delay (default `30000`) |
715715
| `jitter` | `boolean` | Randomize the delay to avoid a thundering herd (default `false`) |
716716

717+
`maxRetries` counts the **re-runs**, not the total attempts: `maxRetries: 2`
718+
runs the flow up to three times.
719+
720+
### `strategy: 'retry'` has to say how many times
721+
722+
There is no default retry count. `strategy: 'retry'` without `maxRetries` — or
723+
with `maxRetries: 0` — is refused when the flow is registered:
724+
725+
```typescript
726+
// ❌ rejected: "retry" that retries zero times is just "fail"
727+
errorHandling: { strategy: 'retry' }
728+
729+
// ✅ state the attempts
730+
errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 5000 }
731+
```
732+
733+
A retry re-runs the **whole flow from the start**, so every node that already
734+
succeeded runs again — records get created again, callouts fire again. That is
735+
too consequential a number to pick on the author's behalf, and picking `0`
736+
would make opting into `'retry'` do nothing at all. The knobs above are read
737+
only under `'retry'`; a fully spelled-out block under `'fail'` or `'continue'`
738+
is fine and simply ignored.
739+
740+
<Callout type="info">
741+
Before ObjectStack 17 the count depended on how the flow reached the engine:
742+
a flow parsed by `FlowSchema` retried `0` times when the count was unstated,
743+
while a hand-built definition passed straight to the engine retried `3` — the
744+
schema and the engine each carried a default and they disagreed
745+
([#4247](https://github.com/objectstack-ai/objectstack/issues/4247)). The
746+
engine's copy is gone; the schema is the only source, and the case that was
747+
ambiguous is now rejected instead of guessed.
748+
</Callout>
749+
717750
## Discovery & Registration
718751

719752
You almost never call `engine.registerFlow()` directly. The

content/docs/releases/v17.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,17 @@ from `@objectstack/spec` directly.
930930
- **`sys_view_definition`'s all-six `apiMethods` whitelist is dropped** (#3026).
931931
- **`os migrate plan` shows index drift** — index DDL is no longer applied
932932
silently at boot (#3728).
933+
- **A flow's `errorHandling.strategy: 'retry'` must state `maxRetries` (>= 1)**
934+
(#4247). `maxRetries` had two defaults — `.default(0)` in `FlowSchema` and
935+
`?? 3` in the engine's `retryExecution` — so an unstated count retried 0 times
936+
for a flow that had been through the schema and 3 times for a definition
937+
handed to the engine directly. The engine's copy is gone (it reads the parsed
938+
block with no fallback), and the case that was ambiguous is rejected rather
939+
than guessed: retrying zero times is `strategy: 'fail'` under another name,
940+
and a retry re-runs the *whole* flow, so the count is the author's to state.
941+
Fix: write `{ strategy: 'retry', maxRetries: 3 }`, or `strategy: 'fail'` if no
942+
retry was intended. `maxRetries: 0` stays legal under `'fail'` / `'continue'`,
943+
which never read it.
933944

934945
## New capabilities in 17.0.0
935946

docs/protocol-upgrade-guide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ On the wire contract it also retires the `/analytics/query` request ENVELOPE (#3
142142

143143
The close-out sweep finishes the enforce-or-remove worklist across the remaining types: action `shortcut`/`bulkEnabled` (no keydown path; the multi-select toolbar reads the view's bulkActions), flow `active`/`template`/node `outputSchema`/errorHandling `fallbackNodeId` (`active: false` never stopped a flow — `status` is the enforced lifecycle; faults route via per-node fault edges), the inert view keys (list `responsive`/`performance`, form `data`/`defaultSort`/`aria` — list aria/data stay live), dashboard and widget `aria`/`performance`, `agent.knowledge` (declaring sources never scoped retrieval — absorbs the former topics→sources rename), and `skill.triggerPhrases` (phrases were never matched; routing is triggerConditions + the agent allowlist). All pure lossless deletes, each tombstoned at its schema with the prescription.
144144

145+
One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#4247). It carried two defaults — `.default(0)` in FlowSchema and `maxRetries ?? 3` in the engine's retryExecution — and because `??` fires only on `undefined`, an unstated count meant 0 retries for a flow parsed by the schema and 3 for a definition handed to the engine directly: the retry count was a function of the route in, not of the authored flow. The engine's copy is deleted (it reads the parsed block, no fallback), which makes an unstated count unambiguously 0 — and `strategy: 'retry'` that retries zero times is `strategy: 'fail'` under another name, the declared-not-delivered shape ADR-0049 exists to close. The schema therefore requires `maxRetries` at least 1 under `'retry'`, in both spellings (omitted, and an explicit 0). This is the one v17 flow change the chain cannot apply for you: choosing the count is a judgment about re-running the WHOLE flow with its side effects, so it is a semantic TODO rather than a rewrite.
146+
145147
### Mechanical (applied for you)
146148

147149
| Conversion | Surface | Change | Load window |
@@ -169,6 +171,9 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining
169171

170172
### Semantic (delegated to you, with acceptance criteria)
171173

174+
- **`flow-retry-max-retries-required`**`flow.errorHandling.maxRetries (under strategy: 'retry')` → an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'
175+
- Why not automatic: maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition (#4247). With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's.
176+
- Done when: Every flow declaring `errorHandling.strategy: 'retry'` also declares `maxRetries` >= 1, and each count was chosen knowing a retry replays the flow FROM THE START (records re-created, callouts re-fired); flows that never actually wanted retries say `strategy: 'fail'`. No flow fails to register with the maxRetries prescription.
172177
- **`analytics-query-request-envelope-retired`**`api.analyticsQueryRequest.query` → bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)
173178
- Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves.
174179
- Done when: Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.

packages/cli/test/migrate-meta.e2e.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ export default {
6262
type: 'autolaunched',
6363
active: false, // 16: removed (status is the lifecycle)
6464
template: true, // 16: removed (no reader)
65-
errorHandling: { strategy: 'retry', fallbackNodeId: 'n9' }, // 16: fault edges own this
65+
// 16: fallbackNodeId removed (fault edges own this). maxRetries is stated
66+
// because #4247 refuses a zero-attempt 'retry' — the count is the one v17
67+
// flow change the chain surfaces as a semantic TODO instead of rewriting.
68+
errorHandling: { strategy: 'retry', maxRetries: 2, fallbackNodeId: 'n9' },
6669
nodes: [
6770
{ id: 'n1', type: 'start', label: 'Start', outputSchema: { ok: { type: 'boolean' } } }, // 16: removed
6871
{ id: 'n2', type: 'delete_record', label: 'Purge', config: { objectName: 'e2e_ticket', filter: { done: true } } }, // canonical since protocol 11 — must pass through untouched

packages/services/service-automation/src/engine.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3485,24 +3485,35 @@ export class AutomationEngine implements IAutomationService {
34853485
/**
34863486
* Retry execution with exponential backoff, jitter, and recursive protection.
34873487
* Uses an iterative loop with an internal retry flag to prevent recursive call stacking.
3488+
*
3489+
* Reads the PARSED `errorHandling` block straight — no `??` fallbacks
3490+
* (#4247). It used to declare every knob optional and re-state a default
3491+
* for each, and one of those copies disagreed with the schema
3492+
* (`maxRetries ?? 3` against `.default(0)`), which made the retry count a
3493+
* function of how the flow reached the engine rather than of what its
3494+
* author wrote. `this.flows` only ever holds `FlowSchema.parse` output —
3495+
* `registerFlow` parses, and the version-history rollback re-seats a
3496+
* previously parsed snapshot — so every field below is present, and typing
3497+
* the parameter as the parsed shape is what keeps a second set of defaults
3498+
* from growing back here: a knob the spec stops defaulting becomes a
3499+
* compile error, not a silent engine-side guess.
34883500
*/
34893501
private async retryExecution(
34903502
flowName: string,
34913503
context: AutomationContext | undefined,
34923504
startTime: number,
3493-
errorHandling: {
3494-
maxRetries?: number;
3495-
retryDelayMs?: number;
3496-
backoffMultiplier?: number;
3497-
maxRetryDelayMs?: number;
3498-
jitter?: boolean;
3499-
},
3505+
errorHandling: NonNullable<FlowParsed['errorHandling']>,
35003506
): Promise<AutomationResult> {
3501-
const maxRetries = errorHandling.maxRetries ?? 3;
3502-
const baseDelay = errorHandling.retryDelayMs ?? 1000;
3503-
const multiplier = errorHandling.backoffMultiplier ?? 1;
3504-
const maxDelay = errorHandling.maxRetryDelayMs ?? 30000;
3505-
const useJitter = errorHandling.jitter ?? false;
3507+
// `maxRetries >= 1` is guaranteed under `strategy: 'retry'` — the schema
3508+
// refuses the zero-attempt spelling of "retry" (#4247), so reaching this
3509+
// method always means at least one re-run.
3510+
const {
3511+
maxRetries,
3512+
retryDelayMs: baseDelay,
3513+
backoffMultiplier: multiplier,
3514+
maxRetryDelayMs: maxDelay,
3515+
jitter: useJitter,
3516+
} = errorHandling;
35063517

35073518
let lastError = 'Max retries exceeded';
35083519
for (let i = 0; i < maxRetries; i++) {

0 commit comments

Comments
 (0)