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
94 changes: 94 additions & 0 deletions .changeset/retry-vocab-converge-inline-blocks.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
---
"@objectstack/spec": major
"@objectstack/service-automation": major
---

**The retry policy's last two dialects converge** (#4964 `flow.errorHandling`, #4962
`ETLPipeline.retry`).

#4661 converged the retry policy onto one declaration. It converged the two shapes that
published the **same exported name** (`RetryPolicy` from `./automation` and `./system` —
the #4411 trap), because that is the question the dual-source instrument asks. Two more
encodings of the identical concept were outside its vision *by construction*: both are
anonymous inline `z.object`s nested in a bigger schema, with no exported name to collide.

The cost of the gap fell on the author who did the right thing. `shared/retry-policy.zod.ts`
tombstoned `retryDelayMs` and told them to write `backoffMs` — and `flow.errorHandling`
then **rejected** `backoffMs` and demanded `retryDelayMs`. Reading the newer file was
punished, and which file an AI author reads first is arbitrary.

All four surfaces — `job.retryPolicy`, a `try_catch` node's `retry`, `flow.errorHandling`
and an ETL pipeline's `retry` — now build from one shared shape.

## FROM → TO

### `flow.errorHandling` (#4964)

| | FROM | TO |
|---|---|---|
| base delay | `retryDelayMs`, min 0, default 1000 | **`backoffMs`**, min 0, default 1000 |
| `maxRetries` / `backoffMultiplier` / `maxRetryDelayMs` / `jitter` | *(already identical)* | unchanged |
| `strategy` | `'fail' \| 'retry' \| 'continue'` | unchanged — it selects *whether* the policy runs, so it stays outside it |

One key, one word, no default changes. Every other key, bound and default already
matched the converged policy, which is exactly why the divergence survived a release:
it looked reviewed.

### `ETLPipeline.retry` (#4962)

| | FROM | TO |
|---|---|---|
| count | `maxAttempts`, min 0, **default 3**, unbounded | **`maxRetries`**, 0–**10**, **default 0** |
| base delay | `backoffMs`, default **60000** | `backoffMs`, default **1000** |
| `backoffMultiplier` | *(absent)* | ≥1, default 1 |
| `maxRetryDelayMs` | *(absent)* | default 30000 |
| `jitter` | *(absent)* | default false |

## What you must change

**1. Rename `retryDelayMs` → `backoffMs`** in any `flow.errorHandling` block. The value
(milliseconds before the first retry) is unchanged. The old spelling is **tombstoned**,
not deleted, so it rejects with the rename rather than being silently stripped, and
`os migrate meta --from 16` (the `retry-policy-converged` conversion, now with a
flow-level branch) rewrites it for you.

**2. Rename `maxAttempts` → `maxRetries`** in any `ETLPipeline.retry` block. **The number
does not change** — both counted the retries *after* the initial attempt. Do **not**
subtract one: that adjustment belongs to `integration/connector.zod.ts`'s
identically-spelled `RetryConfig.maxAttempts`, which *includes* the first attempt and is
deliberately **not** part of this convergence.

**3. If an ETL pipeline relied on the implicit retry count, write it out.** `retry: {}`
used to mean three re-runs 60s apart; it now means **none**. State `maxRetries: 3` (and
`backoffMs: 60000` for the old delay) to keep the old behaviour.

## Why the ETL default flips to 0

Not merely to follow #4661. An ETL destination is a foreign system *by definition* — a
warehouse, an API, someone else's database. A silent retry against a non-idempotent
destination is a **duplicate write**: a second invoice, a second export, a second
webhook. Default 0 makes retrying something an author states, and thereby claims
idempotency for. An unstated key is precisely where LLM-authored metadata hides this.

## Migration surface

**`flow.errorHandling`** is live: `service-automation`'s `retryExecution` reads the key
(it now destructures `backoffMs`), and the D2 conversion covers stored and authored
flows, so no deployed stack changes behaviour.

**`ETLPipeline.retry` has an empty migration surface today, and that is why now was the
moment.** `etl.zod.ts` has no parse site in objectstack / objectui / cloud (批 12's
measurement) and an ETL pipeline is not a `defineStack` collection, so there is no stored
document a conversion could walk — it deliberately gets a tombstone and **no** D2 step,
rather than a walker advertising coverage that does not exist. Once an ETL engine lands,
flipping this default stops being a schema edit and becomes a behaviour change to every
deployed pipeline.

## Also

The two automation retry surfaces now carry the **same** curated unknown-key table, so an
author learns one lesson instead of two, and `retry-policy.test.ts` gains a
concept-level guard: all four surfaces are asserted to expose the same key set and the
same defaults, by parse rather than by inspecting how each obtains them. Adding a fifth
retry surface without wiring it to the shared shape now fails a test — which is the check
that would have caught both of these issues, and the one the name-based scan could never be.
18 changes: 14 additions & 4 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -458,7 +458,7 @@ events).
try: { nodes: [{ id: 'charge', type: 'http', label: 'Charge', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] },
errorVariable: '$error',
retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2 },
retry: { maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2 },
},
}
```
Expand DownExpand Up@@ -956,15 +956,25 @@ Configure how errors are handled during flow execution:
errorHandling: {
strategy: 'retry', // 'fail' | 'retry' | 'continue'
maxRetries: 3,
retryDelayMs: 5000,
backoffMs: 5000,
}
```

The retry knobs are the **one** retry policy the platform has (`RetryPolicySchema`),
shared with `job.retryPolicy`, a `try_catch` node's `retry` and an ETL pipeline's
`retry`. A spelling learned on any one of them is correct on all of them.

<Callout type="warn">
**17.0.0 breaking:** the base delay here was `retryDelayMs` and is now
`backoffMs` — same value, same meaning. `retryDelayMs` is rejected with the
rename; `os migrate meta --from 16` rewrites it for you.
</Callout>

| Property | Type | Description |
| :--- | :--- | :--- |
| `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'`) |
| `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) |
| `retryDelayMs` | `number` | Delay between retries (ms) (default `1000`) |
| `backoffMs` | `number` | Base delay before the first retry (ms); subsequent delays multiply by `backoffMultiplier` (default `1000`) |
| `backoffMultiplier` | `number` | Exponential backoff multiplier (default `1`) |
| `maxRetryDelayMs` | `number` | Ceiling on the backed-off delay (default `30000`) |
| `jitter` | `boolean` | Randomize the delay to avoid a thundering herd (default `false`) |
Expand All@@ -982,7 +992,7 @@ with `maxRetries: 0` — is refused when the flow is registered:
errorHandling: { strategy: 'retry' }

// ✅ state the attempts
errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 5000 }
errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 5000 }
```

A retry re-runs the **whole flow from the start**, so every node that already
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/automation-api.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,7 +109,7 @@ const result = AutomationApiErrorCode.parse(data);
| **edges** | `{ id: string; source: string; target: string; condition?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; … }[]` | ✅ | Flow connections |
| **active** | `any` | 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. |
| **runAs** | `Enum<'system' \| 'user'>` | optional | 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; retryDelayMs?: 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 |
| **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. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/etl.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,7 +189,7 @@ const result = ETLDestinationSchema.parse(data);
| **syncMode** | `Enum<'full' \| 'incremental' \| 'cdc'>` | optional | Sync mode |
| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron schedule expression |
| **enabled** | `boolean` | optional | Pipeline enabled status |
| **retry** | `{ maxAttempts?: integer; backoffMs?: integer }` | optional | Retry configuration |
| **retry** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry configuration |
| **notifications** | `{ onSuccess?: string[]; onFailure?: string[] }` | optional | Notification settings |
| **tags** | `string[]` | optional | Pipeline tags |
| **metadata** | `Record<string, any>` | optional | Custom metadata |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/flow.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,7 +59,7 @@ const result = FlowSchema.parse(data);
| **edges** | `{ id: string; source: string; target: string; condition?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; … }[]` | ✅ | Flow connections |
| **active** | `any` | 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. |
| **runAs** | `Enum<'system' \| 'user'>` | optional | 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; retryDelayMs?: 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 |
| **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. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/retry-policy.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ const result = RetryPolicySchema.parse(data);
| **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. |
| **retryDelayMs** | `any` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry`, `flow.errorHandling` and an ETL pipeline'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. |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/retry-policy.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ const result = RetryPolicySchema.parse(data);
| **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. |
| **retryDelayMs** | `any` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry`, `flow.errorHandling` and an ETL pipeline'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. |


---
Expand Down
Loading
Loading