From 7ff59d9037602ee0eea5c6746b29c68a5ffb5a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:05:31 +0000 Subject: [PATCH 1/2] feat(spec): TryCatchErrorValueSchema declares the optional open-string code key the try_catch engine binds Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .changeset/try-catch-error-value-code-key.md | 11 ++++ .../spec/src/automation/control-flow.test.ts | 54 +++++++++++++++++++ .../spec/src/automation/control-flow.zod.ts | 19 ++++++- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .changeset/try-catch-error-value-code-key.md diff --git a/.changeset/try-catch-error-value-code-key.md b/.changeset/try-catch-error-value-code-key.md new file mode 100644 index 0000000000..0fbbeed2da --- /dev/null +++ b/.changeset/try-catch-error-value-code-key.md @@ -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. diff --git a/packages/spec/src/automation/control-flow.test.ts b/packages/spec/src/automation/control-flow.test.ts index e038619c59..5164d5828e 100644 --- a/packages/spec/src/automation/control-flow.test.ts +++ b/packages/spec/src/automation/control-flow.test.ts @@ -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 }; + 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 }; + 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 diff --git a/packages/spec/src/automation/control-flow.zod.ts b/packages/spec/src/automation/control-flow.zod.ts index 5b872e6c8e..23fd589344 100644 --- a/packages/spec/src/automation/control-flow.zod.ts +++ b/packages/spec/src/automation/control-flow.zod.ts @@ -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'), }, )); @@ -336,6 +336,21 @@ export type TryCatchConfigParsed = z.infer; * 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. @@ -345,6 +360,8 @@ export type TryCatchConfigParsed = z.infer; 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() From 214ae52115974b574704006f4cce19cf064c582e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:18:16 +0000 Subject: [PATCH 2/2] chore(spec): regenerate authorable-surface and the control-flow reference for the new code key Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- content/docs/references/automation/control-flow.mdx | 3 ++- packages/spec/authorable-surface/automation.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx index 684be1761d..2c17467549 100644 --- a/content/docs/references/automation/control-flow.mdx +++ b/content/docs/references/automation/control-flow.mdx @@ -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` @@ -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 | diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 204b7ffd7c..f704ed7a03 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -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",