Skip to content

Commit 645e27c

Browse files
committed
feat(spec)!: converge RetryPolicy onto one declaration (#4661)
`@objectstack/spec/automation` and `@objectstack/spec/system` both exported `RetryPolicySchema` / `RetryPolicy` resolving to DIFFERENT declarations, so the shape a consumer got depended only on the import path (the #4411 trap). They were never two concepts: the `try_catch` node's `retry` region and `job.retryPolicy` both compute `delay = base * multiplier^(retry-1)`, and both executors implemented that identical formula. One declaration now lives in `shared/retry-policy.zod.ts`, re-exported by both entries, carrying the union of what the two sides could express. Because the published def key is derived from the entry namespace, both `automation/RetryPolicy` and `system/RetryPolicy` survive with an identical key set — so the convergence costs exactly ONE authorable key instead of eight. Authorable surface: `automation/RetryPolicy:retryDelayMs` is the single casualty, TOMBSTONED (`retiredKey`) rather than deleted because neither owning schema is `.strict()` — a plain removal would have Zod swallow the authored number and silently fall back to the 1000ms default. Defaults are the half no gate can see: the authorable-surface ratchet compares key sets, and a default is not a key. `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` where automation defaulted 0 / 1. The merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in), and the `retry-policy-converged` conversion writes the pre-17 numbers explicitly into every existing `job.retryPolicy` that omitted them — deployed stacks keep their exact behaviour; only a newly authored omission changes meaning. Filed separately as #4666 (gates are blind to default/constraint changes). `job.retryPolicy` gains `maxRetryDelayMs` and `jitter`, both now enforced in `runWithPolicy` rather than merely declared (ADR-0049). Baseline: 22 -> 20. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL
1 parent ce5242c commit 645e27c

26 files changed

Lines changed: 775 additions & 99 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
**Retry policy converges onto one declaration** (#4661 — the #4535 C8 dual-source cluster).
6+
7+
`@objectstack/spec/automation` and `@objectstack/spec/system` both exported
8+
`RetryPolicySchema` / `RetryPolicy`, resolving to **different declarations** — so the
9+
shape you got depended only on which entry you imported (the #4411 trap). They were
10+
never two concepts: the `try_catch` node's `retry` region and `job.retryPolicy` both
11+
compute `delay = base * multiplier^(retry-1)`, and both executors implemented that
12+
identical formula. There is now one declaration, re-exported by both entries, carrying
13+
the union of what the two sides could express.
14+
15+
## FROM → TO
16+
17+
| | FROM `./automation` | FROM `./system` | TO (both entries) |
18+
|---|---|---|---|
19+
| base delay | `retryDelayMs`, min 0, default 1000 | `backoffMs`, positive, default 1000 | **`backoffMs`**, min 0, default 1000 |
20+
| `maxRetries` | 0–10, default **0** | ≥0 unbounded, default **3** | 0–**10**, default **0** |
21+
| `backoffMultiplier` |**1**, default **1** | positive, default **2** |**1**, default **1** |
22+
| `maxRetryDelayMs` | default 30000 | *(absent)* | default 30000 |
23+
| `jitter` | default false | *(absent)* | default false |
24+
| `RetryPolicy` type | `z.input` | `z.infer` | `z.input` (+ new `RetryPolicyParsed` for `z.infer`) |
25+
26+
## What you must change
27+
28+
**1. Rename `retryDelayMs``backoffMs`** in any `try_catch` node's `retry` block.
29+
The value (milliseconds before the first retry) is unchanged. The old spelling is
30+
**tombstoned**, not deleted — it rejects with the rename prescription instead of being
31+
silently swallowed, because neither owning schema is `.strict()`. Automated:
32+
33+
```
34+
os migrate meta --from 16
35+
```
36+
37+
**2. Nothing for existing jobs — but read this if you author new ones.** `maxRetries`
38+
now defaults to **0** and `backoffMultiplier` to **1**, where `job.retryPolicy`
39+
previously defaulted to 3 and 2. Left alone that would silently stop deployed jobs from
40+
retrying, so the `retry-policy-converged` conversion **writes the pre-17 numbers
41+
explicitly into every existing `job.retryPolicy`** that omitted them:
42+
43+
```jsonc
44+
// before // after `os migrate meta`
45+
{ "backoffMs": 5000 } { "backoffMs": 5000, "maxRetries": 3, "backoffMultiplier": 2 }
46+
```
47+
48+
Deployed stacks therefore keep their exact behaviour. What changes is what a **newly
49+
authored** omission means: declaring a retry block without `maxRetries` now means *no
50+
retry*. Retry is opt-in because a retry replays whatever the attempt already did — a job
51+
handler's writes and callouts, a `try` region's side effects — and an implicit replay is
52+
the failure mode hardest to catch in tests and most expensive in production. (The same
53+
reading is already recorded for flow-level retry in `flow-retry-max-retries-required`,
54+
#4247.)
55+
56+
> This defaults change is the part **no gate can see**: the authorable-surface ratchet
57+
> compares key sets, and a default is not a key. It is called out here because a
58+
> changeset is the only channel that carries it.
59+
60+
**3. Two bounds now apply to jobs that did not have them**`maxRetries` is capped at
61+
**10** and `backoffMultiplier` floored at **1**. Both fail loudly at parse time rather
62+
than being silently reinterpreted; neither has a lossless rewrite, so they are recorded
63+
as the `job-retry-policy-constraints-tightened` semantic migration note. A multiplier
64+
below 1 described a delay that *shrinks* on each attempt — retrying a failing dependency
65+
ever faster, the opposite of backoff.
66+
67+
**4. `import type { RetryPolicy } from '@objectstack/spec/system'` is now the input
68+
shape** (every key optional) rather than the post-parse shape. Use the new
69+
`RetryPolicyParsed` where you need defaults applied.
70+
71+
## What you gain
72+
73+
`job.retryPolicy` accepts **`maxRetryDelayMs`** (ceiling on a single backoff delay) and
74+
**`jitter`** (randomize each delay into [50%, 100%]). Both are enforced by
75+
`runWithPolicy`, not merely declared — jitter is what stops a fleet of jobs that failed
76+
on one outage from retrying in lockstep.

content/docs/references/automation/control-flow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ const result = FlowRegionSchema.parse(data);
149149
| **try** | `{ nodes: { id: string; type: string; label: string; config?: Record<string, any>; … }[]; edges?: { id: string; source: string; target: string; condition?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; … }[] }` || Protected region |
150150
| **catch** | `{ nodes: { id: string; type: string; label: string; config?: Record<string, any>; … }[]; edges?: { id: string; source: string; target: string; condition?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; … }[] }` | optional | Handler region run when the try region fails |
151151
| **errorVariable** | `string` | optional | Variable holding the caught error in the catch region |
152-
| **retry** | `{ maxRetries?: integer; retryDelayMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Optional retry policy for the try region |
152+
| **retry** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Optional retry policy for the try region |
153153

154154

155155
---

content/docs/references/automation/job.mdx

Lines changed: 0 additions & 34 deletions
This file was deleted.

content/docs/references/automation/meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
"webhook",
1818
"---Approvals & Jobs---",
1919
"approval",
20-
"job",
2120
"---More---",
2221
"builtin-node-config",
2322
"events-core",
2423
"flow-function",
2524
"io-node-config",
25+
"retry-policy",
2626
"schemaless-node-config"
2727
]
2828
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
title: Retry Policy
3+
description: Retry Policy protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
## TypeScript Usage
9+
10+
```typescript
11+
import { RetryPolicySchema } from '@objectstack/spec/automation';
12+
import type { RetryPolicy } from '@objectstack/spec/automation';
13+
14+
// Validate data
15+
const result = RetryPolicySchema.parse(data);
16+
```
17+
18+
---
19+
20+
## RetryPolicy
21+
22+
### Properties
23+
24+
| Property | Type | Required | Description |
25+
| :--- | :--- | :--- | :--- |
26+
| **maxRetries** | `integer` || Retry attempts after the initial one. 0 (the default) means no retry — state a count to opt in. |
27+
| **backoffMs** | `integer` || Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier |
28+
| **backoffMultiplier** | `number` || Exponential backoff multiplier; 1 (the default) keeps the delay flat |
29+
| **maxRetryDelayMs** | `integer` || Ceiling for a single backoff delay (ms) |
30+
| **jitter** | `boolean` || Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries |
31+
| **retryDelayMs** | `any` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661) — the retry policy now has one spelling for its base delay across `job.retryPolicy` and a `try_catch` node's `retry`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. `os migrate meta --from 16` rewrites it for you. |
32+
33+
34+
---
35+

content/docs/references/system/job.mdx

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Schedule jobs using cron expressions
1616
## TypeScript Usage
1717

1818
```typescript
19-
import { CronScheduleSchema, IntervalScheduleSchema, JobSchema, JobExecutionSchema, JobExecutionStatus, OnceScheduleSchema, RetryPolicySchema, ScheduleSchema } from '@objectstack/spec/system';
20-
import type { CronSchedule, IntervalSchedule, Job, JobExecution, JobExecutionStatus, OnceSchedule, RetryPolicy, Schedule } from '@objectstack/spec/system';
19+
import { CronScheduleSchema, IntervalScheduleSchema, JobSchema, JobExecutionSchema, JobExecutionStatus, OnceScheduleSchema, ScheduleSchema } from '@objectstack/spec/system';
20+
import type { CronSchedule, IntervalSchedule, Job, JobExecution, JobExecutionStatus, OnceSchedule, Schedule } from '@objectstack/spec/system';
2121

2222
// Validate data
2323
const result = CronScheduleSchema.parse(data);
@@ -62,7 +62,7 @@ const result = CronScheduleSchema.parse(data);
6262
| **description** | `string` | optional | Job description / purpose |
6363
| **schedule** | `{ type: 'cron'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` || Job schedule configuration |
6464
| **handler** | `string` || Handler function name (must match a key in `defineStack({ functions })`) |
65-
| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = backoffMs * backoffMultiplier^(retry-1)) up to maxRetries retries after the initial attempt (#3494). Omit for the legacy single-attempt behavior. |
65+
| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt (#3494). Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 (#4661) — state a count to opt in. |
6666
| **timeout** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout" (#3494). The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. |
6767
| **enabled** | `boolean` | optional | Whether the job is enabled |
6868
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
@@ -114,19 +114,6 @@ const result = CronScheduleSchema.parse(data);
114114
| **at** | `string` || ISO 8601 datetime when to execute |
115115

116116

117-
---
118-
119-
## RetryPolicy
120-
121-
### Properties
122-
123-
| Property | Type | Required | Description |
124-
| :--- | :--- | :--- | :--- |
125-
| **maxRetries** | `integer` || Maximum number of retry attempts |
126-
| **backoffMs** | `integer` || Initial backoff delay in milliseconds |
127-
| **backoffMultiplier** | `number` || Multiplier for exponential backoff |
128-
129-
130117
---
131118

132119
## Schedule

content/docs/references/system/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"collaboration",
4646
"doc",
4747
"---More---",
48-
"metadata-types"
48+
"metadata-types",
49+
"retry-policy"
4950
]
5051
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
title: Retry Policy
3+
description: Retry Policy protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
## TypeScript Usage
9+
10+
```typescript
11+
import { RetryPolicySchema } from '@objectstack/spec/system';
12+
import type { RetryPolicy } from '@objectstack/spec/system';
13+
14+
// Validate data
15+
const result = RetryPolicySchema.parse(data);
16+
```
17+
18+
---
19+
20+
## RetryPolicy
21+
22+
### Properties
23+
24+
| Property | Type | Required | Description |
25+
| :--- | :--- | :--- | :--- |
26+
| **maxRetries** | `integer` || Retry attempts after the initial one. 0 (the default) means no retry — state a count to opt in. |
27+
| **backoffMs** | `integer` || Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier |
28+
| **backoffMultiplier** | `number` || Exponential backoff multiplier; 1 (the default) keeps the delay flat |
29+
| **maxRetryDelayMs** | `integer` || Ceiling for a single backoff delay (ms) |
30+
| **jitter** | `boolean` || Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries |
31+
| **retryDelayMs** | `any` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661) — the retry policy now has one spelling for its base delay across `job.retryPolicy` and a `try_catch` node's `retry`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. `os migrate meta --from 16` rewrites it for you. |
32+
33+
34+
---
35+

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,15 +477,15 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
477477
| `validation.zod.ts` | 6 | authorable | **strict as of #4001 batch 3b** — a `z.lazy()` discriminated union, so the one-call conversion does not apply: each of the six variants builds its own `strictObject` from a shared `BASE_VALIDATION_SHAPE`. Closing the base alone would have rejected correctly but suggested from the SHARED keys only, so a typo of a variant's own key (`transtions``transitions`) would get no rename. Site count 1 → 6 because the six variants are now object sites in their own right. The ADR-0010 envelope lives in the shared shape, so all six inherit it |
478478
| `field-value.zod.ts` / `seed.zod.ts` | 1+1 | mixed (p) | `seed` is strict (registered-types batch) |
479479

480-
### `automation/`88 sites
480+
### `automation/`87 sites
481481

482482
| File | Sites | Class | Note |
483483
|---|---|---|---|
484484
| `flow.zod.ts` | 11 | authorable | **strict as of #4001** (4 schemas; `FlowVersionHistorySchema` is runtime — stays tolerant) |
485485
| `sync.zod.ts` / `etl.zod.ts` | 12+10 | authorable (p) | authored pipelines — **candidates** |
486486
| `execution.zod.ts` | 13 | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged |
487487
| `state-machine.zod.ts` | 7 | authorable (p) | |
488-
| `control-flow.zod.ts` | 6 | authorable (p) | validated structurally by `validateControlFlow` |
488+
| `control-flow.zod.ts` | 5 | authorable (p) | validated structurally by `validateControlFlow`. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts``./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs``backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling |
489489
| `bpmn-interop.zod.ts` | 5 | wire (p) | interop import shapes |
490490
| `approval.zod.ts` | 4 | authorable | **strict as of #4001 step 3** — all four authoring schemas (node config / approver / escalation / decision-output). The published JSON schema carries `additionalProperties: false` into the Studio form AND `registerFlow()` config validation (#4027/#4040), so an unknown key in an approval node's `config` is rejected at registration too — verified: `z.toJSONSchema` on the strict lazySchema does not throw (#3746 hazard checked) |
491491
| `node-executor.zod.ts` | 4 | wire | executor contract |

0 commit comments

Comments
 (0)