Skip to content

Commit 581d8f8

Browse files
claude[bot]claude
andauthored
feat(spec): TryCatchErrorValueSchema declares the optional open-string code key the try_catch engine binds (#15672)
* feat(spec): TryCatchErrorValueSchema declares the optional open-string code key the try_catch engine binds Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore(spec): regenerate authorable-surface and the control-flow reference for the new code key Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd55558 commit 581d8f8

5 files changed

Lines changed: 86 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `TryCatchErrorValueSchema` declares the `code` key the `try_catch` engine binds (#14954)
6+
7+
`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`.
8+
9+
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.
10+
11+
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.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ const result = FlowRegionSchema.parse(data);
231231
| :--- | :--- | :--- | :--- |
232232
| **try** | `{ nodes: object[]; edges?: object[] }` || Protected region |
233233
| **catch** | `{ nodes: object[]; edges?: object[] }` | optional | Handler region run when the try region fails |
234-
| **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 |
234+
| **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 |
235235
| **retry** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Optional retry policy for the try region |
236236

237237
### Nested Shape: `TryCatchConfig.try`
@@ -270,6 +270,7 @@ const result = FlowRegionSchema.parse(data);
270270
| :--- | :--- | :--- | :--- |
271271
| **nodeId** | `string` || Node the failure is attributed to |
272272
| **message** | `string` || Message of the error that ended the try region, after any retries |
273+
| **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 |
273274
| **iteration** | `integer` | optional | Zero-based iteration of the enclosing loop when the failure happened inside a loop body; absent outside a loop |
274275
| **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 |
275276

packages/spec/authorable-surface/automation.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@
349349
"automation/TryCatchConfig:errorVariable",
350350
"automation/TryCatchConfig:retry",
351351
"automation/TryCatchConfig:try",
352+
"automation/TryCatchErrorValue:code",
352353
"automation/TryCatchErrorValue:item",
353354
"automation/TryCatchErrorValue:iteration",
354355
"automation/TryCatchErrorValue:message",

packages/spec/src/automation/control-flow.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,60 @@ describe('TryCatchErrorValueSchema', () => {
411411
expect(TryCatchErrorValueSchema.safeParse({ nodeId: 'guard', message: 'x', iteration: -1 }).success).toBe(false);
412412
expect(TryCatchErrorValueSchema.safeParse({ nodeId: 'guard', message: 'x', iteration: 1.5 }).success).toBe(false);
413413
});
414+
415+
// #14954 — `code` (#14419): the engine binds the failing node's
416+
// platform-classified error code beside `nodeId` / `message`, so a catch
417+
// region can DISCRIMINATE ("the row is already there" vs "the store is
418+
// down") by branching on `$error.code` instead of parsing `message`. This
419+
// schema is a plain `z.object`, so an undeclared key is STRIPPED on any
420+
// round-trip through it — which is exactly what happened while the engine
421+
// bound a key the ONE shared shape did not declare. These pins hold the
422+
// declaration equal to the binding.
423+
it('preserves `code` on a round-trip — the key the engine binds is declared, not stripped', () => {
424+
const input = { nodeId: 'create', message: 'create_record(order) failed: duplicate', code: 'DUPLICATE_RECORD' };
425+
const value = TryCatchErrorValueSchema.parse(input);
426+
expect(value.code).toBe('DUPLICATE_RECORD');
427+
// The WHOLE object, not one key: a plain `z.object` strips silently, so
428+
// only equality of the parsed value with its input proves nothing was lost.
429+
expect(value).toEqual(input);
430+
});
431+
432+
it('`code` is optional — absent means "no classified code", and the key is absent, not `undefined`', () => {
433+
const value = TryCatchErrorValueSchema.parse({ nodeId: 'guard', message: 'card declined' });
434+
expect(value.code).toBeUndefined();
435+
expect(Object.keys(value)).not.toContain('code');
436+
// Row identity and code compose: a loop-bound duplicate carries both.
437+
const both = { nodeId: 'create', message: 'dup', code: 'DUPLICATE_RECORD', iteration: 1, item: { id: 'r2' } };
438+
expect(TryCatchErrorValueSchema.parse(both)).toEqual(both);
439+
});
440+
441+
it('`code` is an OPEN string, not a closed enum — a third-party or tenant-authored code parses (ADR-0112 D3/D4 + #9106)', () => {
442+
// `NodeExecutor` is third-party-registrable and the code vocabulary is
443+
// `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes,
444+
// so a closed type would be false the moment anyone registers an executor.
445+
// Narrowing this key to `StandardErrorCode` is the documented wrong move.
446+
expect(TryCatchErrorValueSchema.parse({ nodeId: 'acme', message: 'x', code: 'ACME_RATE_LIMITED' }).code).toBe('ACME_RATE_LIMITED');
447+
expect(TryCatchErrorValueSchema.parse({ nodeId: 'acme', message: 'x', code: 'DUPLICATE' }).code).toBe('DUPLICATE');
448+
// Open in VALUE, not in TYPE: a non-string `code` is refused AT the key.
449+
const refused = TryCatchErrorValueSchema.safeParse({ nodeId: 'guard', message: 'x', code: 42 });
450+
expect(refused.success).toBe(false);
451+
expect(refused.error!.issues[0]!.path).toEqual(['code']);
452+
expect(refused.error!.issues[0]!.code).toBe('invalid_type');
453+
});
454+
455+
it('the describe text documents `code` — on the value and on `errorVariable` — so the reference page renders it', () => {
456+
// `content/docs/references/automation/control-flow.mdx` is generated from
457+
// these descriptions; this pins the prose the page renders rather than the
458+
// page (which `check:docs` holds equal to the schema).
459+
const opts = { target: 'draft-2020-12', io: 'input', unrepresentable: 'any' } as const;
460+
const value = z.toJSONSchema(TryCatchErrorValueSchema, opts) as { properties?: Record<string, { description?: string }> };
461+
expect(value.properties?.code?.description).toContain('ADR-0112');
462+
expect(value.properties?.code?.description).toContain('not a closed enum');
463+
expect(value.properties?.code?.description).toContain('"nothing failed"');
464+
const config = z.toJSONSchema(TryCatchConfigSchema, opts) as { properties?: Record<string, { description?: string }> };
465+
expect(config.properties?.errorVariable?.description).toContain('`code`');
466+
expect(config.properties?.errorVariable?.description).toContain('$error.code');
467+
});
414468
});
415469

416470
// The sibling-guard question the batch was dispatched to answer: does closing

packages/spec/src/automation/control-flow.zod.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ export const TryCatchConfigSchema = lazySchema(() => strictObject(
314314
try: FlowRegionSchema.describe('Protected region'),
315315
catch: FlowRegionSchema.optional().describe('Handler region run when the try region fails'),
316316
/** Variable the caught error is bound to inside the catch region. */
317-
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'),
317+
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'),
318318
retry: RetryPolicySchema.optional().describe('Optional retry policy for the try region'),
319319
},
320320
));
@@ -336,6 +336,21 @@ export type TryCatchConfigParsed = z.infer<typeof TryCatchConfigSchema>;
336336
* try/catch outside any loop binds neither, so their absence means "not in a
337337
* loop", never "row unknown".
338338
*
339+
* `code` (#14419 / #14954) is the platform-classified error code (ADR-0112)
340+
* the failing node's own result carried — `create_record`'s `DUPLICATE_RECORD`
341+
* is the founding case — bound so a catch region can tell "the row is already
342+
* there" from "the store is down" by branching on `$error.code` instead of
343+
* parsing `message`. Present only when a classified code was carried, so its
344+
* absence means "no classified code", never "nothing failed". It is
345+
* deliberately an OPEN `string`, not `StandardErrorCode` and not the ledger
346+
* union: ADR-0112 D3/D4 with the #9106 amendment make the code vocabulary
347+
* `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes, and
348+
* `NodeExecutor` is third-party-registrable, so a closed type here would be
349+
* false the moment anyone registers an executor that throws its own code.
350+
* The closed-at-every-door rule governs `ApiErrorSchema.code` at an HTTP
351+
* door; this value never crosses one — it is bound in-process, before any
352+
* demotion to `declaredCode` could apply.
353+
*
339354
* A plain `z.object`, closed by convention rather than `strictObject`: this is
340355
* a value the engine assembles, not a surface an author writes, so the
341356
* unknown-key prescription an authoring surface owes has nobody to address.
@@ -345,6 +360,8 @@ export type TryCatchConfigParsed = z.infer<typeof TryCatchConfigSchema>;
345360
export const TryCatchErrorValueSchema = lazySchema(() => z.object({
346361
nodeId: z.string().describe('Node the failure is attributed to'),
347362
message: z.string().describe('Message of the error that ended the try region, after any retries'),
363+
code: z.string().optional()
364+
.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'),
348365
iteration: z.number().int().min(0).optional()
349366
.describe('Zero-based iteration of the enclosing loop when the failure happened inside a loop body; absent outside a loop'),
350367
item: z.unknown().optional()

0 commit comments

Comments
 (0)