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
11 changes: 11 additions & 0 deletions .changeset/try-catch-error-value-code-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/spec": minor
---

feat(spec): `TryCatchErrorValueSchema` declares the `code` key the `try_catch` engine binds (#14954)

`TryCatchErrorValue` — the ONE shape the catch region's author, the engine and the run log share for the value a `try_catch` binds to `errorVariable` (default `$error`) — gains an optional `code: string`: the platform-classified error code (ADR-0112) the failing node's own result carried, e.g. `create_record`'s `DUPLICATE_RECORD`. The engine has bound it since `@objectstack/service-automation`'s #14419 change; the schema was a plain `z.object` that did not declare it, so a round-trip through the declared shape silently STRIPPED the key the engine had put there, and the generated reference page documented four keys where the runtime binds five. The `errorVariable` description on `TryCatchConfig` names `code` too, so the authorable surface documents branching on `$error.code`.

Typed as an open `string`, deliberately not `StandardErrorCode` and not the ledger union: ADR-0112 D3/D4 with the #9106 amendment make the code vocabulary `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes, and `NodeExecutor` is third-party-registrable, so a closed type would be false the moment anyone registers an executor that throws its own code. The closed-at-every-door rule governs `ApiErrorSchema.code` at an HTTP door; this value is bound in-process and never crosses one.

Additive and optional: every value that parsed before parses byte-identically, and a binding without a classified code still carries no `code` key — absent means "no classified code", never "nothing failed". Semver: a new optional key on a published schema widens the accept set and the exported `TryCatchErrorValue` type without retiring or renaming anything ⇒ `minor`; no ADR-0087 entry is owed because there is nothing an upgrader must migrate.
3 changes: 2 additions & 1 deletion content/docs/references/automation/control-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ const result = FlowRegionSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **try** | `{ nodes: object[]; edges?: object[] }` | ✅ | Protected region |
| **catch** | `{ nodes: object[]; edges?: object[] }` | optional | Handler region run when the try region fails |
| **errorVariable** | `string` | optional (default: `"$error"`) | Variable holding the caught error in the catch region — a `TryCatchErrorValue`: `nodeId`, `message`, and `iteration` / `item` when the failure happened inside a loop body |
| **errorVariable** | `string` | optional (default: `"$error"`) | Variable holding the caught error in the catch region — a `TryCatchErrorValue`: `nodeId`, `message`, `code` when the failing node carried a platform-classified error code (ADR-0112 — branch on `$error.code` to tell "the row is already there" from "the store is down"), and `iteration` / `item` when the failure happened inside a loop body |
| **retry** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Optional retry policy for the try region |

### Nested Shape: `TryCatchConfig.try`
Expand Down Expand Up @@ -270,6 +270,7 @@ const result = FlowRegionSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **nodeId** | `string` | ✅ | Node the failure is attributed to |
| **message** | `string` | ✅ | Message of the error that ended the try region, after any retries |
| **code** | `string` | optional | Platform-classified error code (ADR-0112) of the failure that ended the try region, e.g. `create_record`'s `DUPLICATE_RECORD`; present only when the failing node's own result carried one, so a catch region branching on `$error.code` treats "unset" as "no classified code", never as "nothing failed". An open `string`, not a closed enum: the vocabulary is `StandardErrorCode` plus registered ledger codes plus tenant-authored codes, and third-party node executors bind their own |
| **iteration** | `integer` | optional | Zero-based iteration of the enclosing loop when the failure happened inside a loop body; absent outside a loop |
| **item** | `any` | optional | The loop item being processed (the enclosing loop's `iteratorVariable` value) when the failure happened inside a loop body; absent outside a loop |

Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@
"automation/TryCatchConfig:errorVariable",
"automation/TryCatchConfig:retry",
"automation/TryCatchConfig:try",
"automation/TryCatchErrorValue:code",
"automation/TryCatchErrorValue:item",
"automation/TryCatchErrorValue:iteration",
"automation/TryCatchErrorValue:message",
Expand Down
54 changes: 54 additions & 0 deletions packages/spec/src/automation/control-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,60 @@ describe('TryCatchErrorValueSchema', () => {
expect(TryCatchErrorValueSchema.safeParse({ nodeId: 'guard', message: 'x', iteration: -1 }).success).toBe(false);
expect(TryCatchErrorValueSchema.safeParse({ nodeId: 'guard', message: 'x', iteration: 1.5 }).success).toBe(false);
});

// #14954 — `code` (#14419): the engine binds the failing node's
// platform-classified error code beside `nodeId` / `message`, so a catch
// region can DISCRIMINATE ("the row is already there" vs "the store is
// down") by branching on `$error.code` instead of parsing `message`. This
// schema is a plain `z.object`, so an undeclared key is STRIPPED on any
// round-trip through it — which is exactly what happened while the engine
// bound a key the ONE shared shape did not declare. These pins hold the
// declaration equal to the binding.
it('preserves `code` on a round-trip — the key the engine binds is declared, not stripped', () => {
const input = { nodeId: 'create', message: 'create_record(order) failed: duplicate', code: 'DUPLICATE_RECORD' };
const value = TryCatchErrorValueSchema.parse(input);
expect(value.code).toBe('DUPLICATE_RECORD');
// The WHOLE object, not one key: a plain `z.object` strips silently, so
// only equality of the parsed value with its input proves nothing was lost.
expect(value).toEqual(input);
});

it('`code` is optional — absent means "no classified code", and the key is absent, not `undefined`', () => {
const value = TryCatchErrorValueSchema.parse({ nodeId: 'guard', message: 'card declined' });
expect(value.code).toBeUndefined();
expect(Object.keys(value)).not.toContain('code');
// Row identity and code compose: a loop-bound duplicate carries both.
const both = { nodeId: 'create', message: 'dup', code: 'DUPLICATE_RECORD', iteration: 1, item: { id: 'r2' } };
expect(TryCatchErrorValueSchema.parse(both)).toEqual(both);
});

it('`code` is an OPEN string, not a closed enum — a third-party or tenant-authored code parses (ADR-0112 D3/D4 + #9106)', () => {
// `NodeExecutor` is third-party-registrable and the code vocabulary is
// `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes,
// so a closed type would be false the moment anyone registers an executor.
// Narrowing this key to `StandardErrorCode` is the documented wrong move.
expect(TryCatchErrorValueSchema.parse({ nodeId: 'acme', message: 'x', code: 'ACME_RATE_LIMITED' }).code).toBe('ACME_RATE_LIMITED');
expect(TryCatchErrorValueSchema.parse({ nodeId: 'acme', message: 'x', code: 'DUPLICATE' }).code).toBe('DUPLICATE');
// Open in VALUE, not in TYPE: a non-string `code` is refused AT the key.
const refused = TryCatchErrorValueSchema.safeParse({ nodeId: 'guard', message: 'x', code: 42 });
expect(refused.success).toBe(false);
expect(refused.error!.issues[0]!.path).toEqual(['code']);
expect(refused.error!.issues[0]!.code).toBe('invalid_type');
});

it('the describe text documents `code` — on the value and on `errorVariable` — so the reference page renders it', () => {
// `content/docs/references/automation/control-flow.mdx` is generated from
// these descriptions; this pins the prose the page renders rather than the
// page (which `check:docs` holds equal to the schema).
const opts = { target: 'draft-2020-12', io: 'input', unrepresentable: 'any' } as const;
const value = z.toJSONSchema(TryCatchErrorValueSchema, opts) as { properties?: Record<string, { description?: string }> };
expect(value.properties?.code?.description).toContain('ADR-0112');
expect(value.properties?.code?.description).toContain('not a closed enum');
expect(value.properties?.code?.description).toContain('"nothing failed"');
const config = z.toJSONSchema(TryCatchConfigSchema, opts) as { properties?: Record<string, { description?: string }> };
expect(config.properties?.errorVariable?.description).toContain('`code`');
expect(config.properties?.errorVariable?.description).toContain('$error.code');
});
});

// The sibling-guard question the batch was dispatched to answer: does closing
Expand Down
19 changes: 18 additions & 1 deletion packages/spec/src/automation/control-flow.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export const TryCatchConfigSchema = lazySchema(() => strictObject(
try: FlowRegionSchema.describe('Protected region'),
catch: FlowRegionSchema.optional().describe('Handler region run when the try region fails'),
/** Variable the caught error is bound to inside the catch region. */
errorVariable: z.string().default('$error').describe('Variable holding the caught error in the catch region — a `TryCatchErrorValue`: `nodeId`, `message`, and `iteration` / `item` when the failure happened inside a loop body'),
errorVariable: z.string().default('$error').describe('Variable holding the caught error in the catch region — a `TryCatchErrorValue`: `nodeId`, `message`, `code` when the failing node carried a platform-classified error code (ADR-0112 — branch on `$error.code` to tell "the row is already there" from "the store is down"), and `iteration` / `item` when the failure happened inside a loop body'),
retry: RetryPolicySchema.optional().describe('Optional retry policy for the try region'),
},
));
Expand All @@ -336,6 +336,21 @@ export type TryCatchConfigParsed = z.infer<typeof TryCatchConfigSchema>;
* try/catch outside any loop binds neither, so their absence means "not in a
* loop", never "row unknown".
*
* `code` (#14419 / #14954) is the platform-classified error code (ADR-0112)
* the failing node's own result carried — `create_record`'s `DUPLICATE_RECORD`
* is the founding case — bound so a catch region can tell "the row is already
* there" from "the store is down" by branching on `$error.code` instead of
* parsing `message`. Present only when a classified code was carried, so its
* absence means "no classified code", never "nothing failed". It is
* deliberately an OPEN `string`, not `StandardErrorCode` and not the ledger
* union: ADR-0112 D3/D4 with the #9106 amendment make the code vocabulary
* `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes, and
* `NodeExecutor` is third-party-registrable, so a closed type here would be
* false the moment anyone registers an executor that throws its own code.
* The closed-at-every-door rule governs `ApiErrorSchema.code` at an HTTP
* door; this value never crosses one — it is bound in-process, before any
* demotion to `declaredCode` could apply.
*
* A plain `z.object`, closed by convention rather than `strictObject`: this is
* a value the engine assembles, not a surface an author writes, so the
* unknown-key prescription an authoring surface owes has nobody to address.
Expand All @@ -345,6 +360,8 @@ export type TryCatchConfigParsed = z.infer<typeof TryCatchConfigSchema>;
export const TryCatchErrorValueSchema = lazySchema(() => z.object({
nodeId: z.string().describe('Node the failure is attributed to'),
message: z.string().describe('Message of the error that ended the try region, after any retries'),
code: z.string().optional()
.describe('Platform-classified error code (ADR-0112) of the failure that ended the try region, e.g. `create_record`\'s `DUPLICATE_RECORD`; present only when the failing node\'s own result carried one, so a catch region branching on `$error.code` treats "unset" as "no classified code", never as "nothing failed". An open `string`, not a closed enum: the vocabulary is `StandardErrorCode` plus registered ledger codes plus tenant-authored codes, and third-party node executors bind their own'),
iteration: z.number().int().min(0).optional()
.describe('Zero-based iteration of the enclosing loop when the failure happened inside a loop body; absent outside a loop'),
item: z.unknown().optional()
Expand Down
Loading