Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .changeset/retry-policy-dual-source-c8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
"@objectstack/spec": major
---

**Retry policy converges onto one declaration** (#4661 — the #4535 C8 dual-source cluster).

`@objectstack/spec/automation` and `@objectstack/spec/system` both exported
`RetryPolicySchema` / `RetryPolicy`, resolving to **different declarations** — so the
shape you got depended only on which entry you imported (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. There is now one declaration, re-exported by both entries, carrying
the union of what the two sides could express.

## FROM → TO

| | FROM `./automation` | FROM `./system` | TO (both entries) |
|---|---|---|---|
| base delay | `retryDelayMs`, min 0, default 1000 | `backoffMs`, positive, default 1000 | **`backoffMs`**, min 0, default 1000 |
| `maxRetries` | 0–10, default **0** | ≥0 unbounded, default **3** | 0–**10**, default **0** |
| `backoffMultiplier` | ≥**1**, default **1** | positive, default **2** | ≥**1**, default **1** |
| `maxRetryDelayMs` | default 30000 | *(absent)* | default 30000 |
| `jitter` | default false | *(absent)* | default false |
| `RetryPolicy` type | `z.input` | `z.infer` | `z.input` (+ new `RetryPolicyParsed` for `z.infer`) |

## What you must change

**1. Rename `retryDelayMs` → `backoffMs`** in any `try_catch` node's `retry` block.
The value (milliseconds before the first retry) is unchanged. The old spelling is
**tombstoned**, not deleted — it rejects with the rename prescription instead of being
silently swallowed, because neither owning schema is `.strict()`. Automated:

```
os migrate meta --from 16
```

**2. Nothing for existing jobs — but read this if you author new ones.** `maxRetries`
now defaults to **0** and `backoffMultiplier` to **1**, where `job.retryPolicy`
previously defaulted to 3 and 2. Left alone that would silently stop deployed jobs from
retrying, so the `retry-policy-converged` conversion **writes the pre-17 numbers
explicitly into every existing `job.retryPolicy`** that omitted them:

```jsonc
// before // after `os migrate meta`
{ "backoffMs": 5000 } { "backoffMs": 5000, "maxRetries": 3, "backoffMultiplier": 2 }
```

Deployed stacks therefore keep their exact behaviour. What changes is what a **newly
authored** omission means: declaring a retry block without `maxRetries` now means *no
retry*. Retry is opt-in because a retry replays whatever the attempt already did — a job
handler's writes and callouts, a `try` region's side effects — and an implicit replay is
the failure mode hardest to catch in tests and most expensive in production. (The same
reading is already recorded for flow-level retry in `flow-retry-max-retries-required`,
#4247.)

> This defaults change is the part **no gate can see**: the authorable-surface ratchet
> compares key sets, and a default is not a key. It is called out here because a
> changeset is the only channel that carries it.

**3. Two bounds now apply to jobs that did not have them** — `maxRetries` is capped at
**10** and `backoffMultiplier` floored at **1**. Both fail loudly at parse time rather
than being silently reinterpreted; neither has a lossless rewrite, so they are recorded
as the `job-retry-policy-constraints-tightened` semantic migration note. A multiplier
below 1 described a delay that *shrinks* on each attempt — retrying a failing dependency
ever faster, the opposite of backoff.

**4. `import type { RetryPolicy } from '@objectstack/spec/system'` is now the input
shape** (every key optional) rather than the post-parse shape. Use the new
`RetryPolicyParsed` where you need defaults applied.

## Not related to `mapping.errorPolicy` (#4509 / #4664, same release)

17.0.0 also retires `mapping.errorPolicy`, whose values included `'retry'`. That is a
different thing on a different type: an inert enum on the stored **mapping**, whose
prescription is "error handling on the import path belongs to the import REQUEST's own
options". It does **not** migrate to a `retryPolicy` block, and nothing in this change
affects it.

## What you gain

`job.retryPolicy` accepts **`maxRetryDelayMs`** (ceiling on a single backoff delay) and
**`jitter`** (randomize each delay into [50%, 100%]). Both are enforced by
`runWithPolicy`, not merely declared — jitter is what stops a fleet of jobs that failed
on one outage from retrying in lockstep.
2 changes: 1 addition & 1 deletion content/docs/references/automation/control-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const result = FlowRegionSchema.parse(data);
| **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 |
| **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 |
| **errorVariable** | `string` | optional | Variable holding the caught error in the catch region |
| **retry** | `{ maxRetries?: integer; retryDelayMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Optional retry policy for the try region |
| **retry** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Optional retry policy for the try region |


---
Expand Down
34 changes: 0 additions & 34 deletions content/docs/references/automation/job.mdx

This file was deleted.

2 changes: 1 addition & 1 deletion content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
"webhook",
"---Approvals & Jobs---",
"approval",
"job",
"---More---",
"builtin-node-config",
"events-core",
"flow-function",
"io-node-config",
"retry-policy",
"schemaless-node-config"
]
}
35 changes: 35 additions & 0 deletions content/docs/references/automation/retry-policy.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: Retry Policy
description: Retry Policy protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

## TypeScript Usage

```typescript
import { RetryPolicySchema } from '@objectstack/spec/automation';
import type { RetryPolicy } from '@objectstack/spec/automation';

// Validate data
const result = RetryPolicySchema.parse(data);
```

---

## RetryPolicy

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **maxRetries** | `integer` | ✅ | Retry attempts after the initial one. 0 (the default) means no retry — state a count to opt in. |
| **backoffMs** | `integer` | ✅ | Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier |
| **backoffMultiplier** | `number` | ✅ | Exponential backoff multiplier; 1 (the default) keeps the delay flat |
| **maxRetryDelayMs** | `integer` | ✅ | Ceiling for a single backoff delay (ms) |
| **jitter** | `boolean` | ✅ | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries |
| **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. |


---

19 changes: 3 additions & 16 deletions content/docs/references/system/job.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ Schedule jobs using cron expressions
## TypeScript Usage

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

// Validate data
const result = CronScheduleSchema.parse(data);
Expand Down Expand Up @@ -62,7 +62,7 @@ const result = CronScheduleSchema.parse(data);
| **description** | `string` | optional | Job description / purpose |
| **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 |
| **handler** | `string` | ✅ | Handler function name (must match a key in `defineStack({ functions })`) |
| **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. |
| **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. |
| **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. |
| **enabled** | `boolean` | optional | Whether the job is enabled |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down Expand Up @@ -114,19 +114,6 @@ const result = CronScheduleSchema.parse(data);
| **at** | `string` | ✅ | ISO 8601 datetime when to execute |


---

## RetryPolicy

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **maxRetries** | `integer` | ✅ | Maximum number of retry attempts |
| **backoffMs** | `integer` | ✅ | Initial backoff delay in milliseconds |
| **backoffMultiplier** | `number` | ✅ | Multiplier for exponential backoff |


---

## Schedule
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/system/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"collaboration",
"doc",
"---More---",
"metadata-types"
"metadata-types",
"retry-policy"
]
}
35 changes: 35 additions & 0 deletions content/docs/references/system/retry-policy.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: Retry Policy
description: Retry Policy protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

## TypeScript Usage

```typescript
import { RetryPolicySchema } from '@objectstack/spec/system';
import type { RetryPolicy } from '@objectstack/spec/system';

// Validate data
const result = RetryPolicySchema.parse(data);
```

---

## RetryPolicy

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **maxRetries** | `integer` | ✅ | Retry attempts after the initial one. 0 (the default) means no retry — state a count to opt in. |
| **backoffMs** | `integer` | ✅ | Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier |
| **backoffMultiplier** | `number` | ✅ | Exponential backoff multiplier; 1 (the default) keeps the delay flat |
| **maxRetryDelayMs** | `integer` | ✅ | Ceiling for a single backoff delay (ms) |
| **jitter** | `boolean` | ✅ | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries |
| **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. |


---

4 changes: 2 additions & 2 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,15 +477,15 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
| `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 |
| `field-value.zod.ts` / `seed.zod.ts` | 1+1 | mixed (p) | `seed` is strict (registered-types batch) |

### `automation/` — 88 sites
### `automation/` — 87 sites

| File | Sites | Class | Note |
|---|---|---|---|
| `flow.zod.ts` | 11 | authorable | **strict as of #4001** (4 schemas; `FlowVersionHistorySchema` is runtime — stays tolerant) |
| `sync.zod.ts` / `etl.zod.ts` | 12+10 | authorable (p) | authored pipelines — **candidates** |
| `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 |
| `state-machine.zod.ts` | 7 | authorable (p) | |
| `control-flow.zod.ts` | 6 | authorable (p) | validated structurally by `validateControlFlow` |
| `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 |
| `bpmn-interop.zod.ts` | 5 | wire (p) | interop import shapes |
| `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) |
| `node-executor.zod.ts` | 4 | wire | executor contract |
Expand Down
Loading
Loading