diff --git a/.changeset/flow-input-schema-invalid-code.md b/.changeset/flow-input-schema-invalid-code.md new file mode 100644 index 0000000000..381c1c7111 --- /dev/null +++ b/.changeset/flow-input-schema-invalid-code.md @@ -0,0 +1,29 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): register `FLOW_INPUT_SCHEMA_INVALID` — the definition-level input-schema refusal becomes a never-dispatched exit with its own ADR-0112 code (#11504, the contract half of the #10025 ruling) + +`AutomationResult.code` gains `'FLOW_INPUT_SCHEMA_INVALID'`, and the code is +registered in the ADR-0112 error-code ledger under `@objectstack/runtime` +beside `FLOW_DISABLED` / `FLOW_NO_START_NODE`. Semantics: a node's static +`config` violates the `inputSchema` its own flow definition declares, so the +engine refuses to dispatch — nothing runs, nothing is written, the result +carries the code and NO `status` (the #9378 never-dispatched class), and a +transport maps it to **422** (unexecutable stored definition, exactly as +`FLOW_NO_START_NODE`). + +Ruled by #10025 (maintainer, 2026-08-20): the refusal is **non-retryable** — +the guard's verdict is a pure function of the flow definition, so re-running +it cannot change the answer. This release ships only the contract vocabulary; +the engine behaviour change is #10025's services half and lands separately. + +**Operator-visible consequence once that services half lands, stated +plainly:** retry accounting and run-log volume change for affected flows. A +`strategy: 'retry'` flow whose node `config` violates its declared +`inputSchema` today burns its whole retry budget (including configured +backoff delays) and writes `1 + maxRetries` identical failed run-log rows; +after the services half it refuses **once**, producing **one** run-log row +carrying `code: 'FLOW_INPUT_SCHEMA_INVALID'` and no `status`. Anything +watching retry counters or paging run history for this exit sees different +numbers for the same flow. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index f83eca4614..c31b3fdf99 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +289 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +290 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | @@ -182,6 +182,7 @@ const result = ApiErrorSchema.parse(data); * `FLOW_CONVERSION_CONFLICT` * `FLOW_DISABLED` * `FLOW_FAILED` +* `FLOW_INPUT_SCHEMA_INVALID` * `FLOW_NO_START_NODE` * `FORBIDDEN` * `FORM_NOT_FOUND` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 84a5f0bc80..67f24b3b9b 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -286,6 +286,7 @@ const result = ErrorCode.parse(data); * `FLOW_CONVERSION_CONFLICT` * `FLOW_DISABLED` * `FLOW_FAILED` +* `FLOW_INPUT_SCHEMA_INVALID` * `FLOW_NO_START_NODE` * `FORBIDDEN` * `FORM_NOT_FOUND` diff --git a/packages/spec/src/api/error-code-ledger.test.ts b/packages/spec/src/api/error-code-ledger.test.ts index 6c951fd29d..d66f45fbc9 100644 --- a/packages/spec/src/api/error-code-ledger.test.ts +++ b/packages/spec/src/api/error-code-ledger.test.ts @@ -212,6 +212,20 @@ describe('ErrorCode (standard ∪ registered)', () => { expect(() => ErrorCode.parse('DUPLICATE')).toThrow(); }); + it('accepts the #10025 definition-level input-schema refusal code (#11504)', () => { + // The ruled contract half of #10025's Option B (maintainer, 2026-08-20): + // the definition-level input-schema refusal is a never-dispatched exit + // with its own code. Registered AHEAD of its producer, deliberately — the + // #10413 → #10576 split shape: the services half (`execute()`'s catch + // short-circuit) is #10025's, blocked on this registration, and asserts + // this exact string by value — so the value is pinned here by value too. + expect(ErrorCode.parse('FLOW_INPUT_SCHEMA_INVALID')).toBe('FLOW_INPUT_SCHEMA_INVALID'); + expect(ERROR_CODE_LEDGER['@objectstack/runtime']).toContain('FLOW_INPUT_SCHEMA_INVALID'); + // Not a synonym of any standard member (FLOW is a token no member + // carries) — registered plainly, no waiver recorded or needed. + expect(standardSynonymOf('FLOW_INPUT_SCHEMA_INVALID')).toBeUndefined(); + }); + it('rejects unregistered, lowercase, and numeric codes', () => { expect(() => ErrorCode.parse('TOTALLY_MADE_UP_CODE')).toThrow(); expect(() => ErrorCode.parse('validation_error')).toThrow(); // pre-ADR-0112 dialect diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 78e59a17c0..260cf2ec7b 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -290,6 +290,31 @@ export const ERROR_CODE_LEDGER = { // `errorFromThrown` (`action-execution.ts`). Reported by the #8087 // dispatcher-vocabulary gate. 'FLOW_FAILED', + // [#11504] the definition-level input-schema refusal: a node's static + // `config` violates the `inputSchema` its own flow definition declares, so + // the engine refused to dispatch — nothing ran, nothing was written, and + // the result carries NO `status` (the #9378 never-dispatched class, beside + // FLOW_DISABLED / FLOW_NO_START_NODE). The guard's verdict is a pure + // function of the flow definition (`validateNodeInputSchemas` reads + // `node.inputSchema` against the static `node.config`; its variables + // parameter is deliberately unused), so re-running it cannot change the + // answer — ruled NON-RETRYABLE by #10025 (maintainer, 2026-08-20, Option B + // taken whole): ONE refusal row carrying this code instead of + // 1 + maxRetries identical `status: 'failed'` rows re-deriving a + // certainty. Answered 422 like FLOW_NO_START_NODE — understood request, + // existing flow, unexecutable definition — and deliberately distinct from + // it: that one says the definition has nothing to dispatch, this one says + // a node's config contradicts the schema the definition itself declares. + // Not a VALIDATION_ERROR synonym: the REQUEST is well-formed — what fails + // is the stored definition. Registered ahead of its producer by design + // (the #10413 → #10576 split shape, applied to #10025 → #11504): the + // emitting half — `execute()`'s catch short-circuiting before + // `retryExecution` in `@objectstack/service-automation` — is #10025's, + // blocked on this row, and asserts this exact string by value. Registered + // HERE and not under the engine's package for the same reason as its + // three FLOW_* siblings: the trigger door, not the producer, is where the + // wire vocabulary is named. + 'FLOW_INPUT_SCHEMA_INVALID', // [#9415] the trigger door refused a flow whose stored definition has no // `start` node — there is nothing to dispatch, so the run never began. // Answered 422 by `respondToFlowTrigger`: understood request, existing diff --git a/packages/spec/src/contracts/automation-service.test.ts b/packages/spec/src/contracts/automation-service.test.ts index 92aaf7d598..b258a52070 100644 --- a/packages/spec/src/contracts/automation-service.test.ts +++ b/packages/spec/src/contracts/automation-service.test.ts @@ -169,6 +169,30 @@ describe('Automation Service Contract', () => { expect(flowEnabled).toBe(true); }); + // [#11504] The #10025 ruling's contract half: a definition-level + // input-schema refusal is a NEVER-DISPATCHED exit with its own + // `AutomationResult.code` member. The compile of the literal below IS the + // assertion — the #9384 reverse verification run forward: before the union + // widened, this exact string was a type error. + it('should accept FLOW_INPUT_SCHEMA_INVALID as a never-dispatched trigger refusal', async () => { + const service: IAutomationService = { + execute: async (): Promise => ({ + success: false, + code: 'FLOW_INPUT_SCHEMA_INVALID', + error: "Node 'sync' config violates its declared inputSchema", + }), + listFlows: async () => ['guarded_flow'], + }; + + const result = await service.execute('guarded_flow'); + expect(result.success).toBe(false); + expect(result.code).toBe('FLOW_INPUT_SCHEMA_INVALID'); + // Never dispatched ⇒ no lifecycle verdict, matching FLOW_DISABLED / + // FLOW_NO_START_NODE (#9378): `status` absent is exactly what separates a + // refused dispatch from a run that dispatched and failed. + expect(result.status).toBeUndefined(); + }); + // [#4127] `getConnectorDescriptors` is the sibling of `getActionDescriptors` // — the other half of the flow designer's `connector_action` pickers — and // was the last of the four dispatcher routes calling a method the contract diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 8918ead2a4..03b85a567f 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -248,12 +248,30 @@ export interface AutomationResult { * retrying. Distinct from `'FLOW_DISABLED'` on purpose: one is a * reversible operational state, the other a malformed definition, and * collapsing them tells an operator to flip a switch that will not help. + * - `'FLOW_INPUT_SCHEMA_INVALID'` — a node's static `config` violates the + * `inputSchema` its own flow definition declares, so the + * definition-level guard refused to dispatch. The verdict is a pure + * function of the flow definition — re-running the guard cannot produce + * a different answer — so the refusal is NON-RETRYABLE (#10025, + * maintainer ruling 2026-08-20: Option B taken whole): the engine + * refuses once instead of burning the whole retry budget re-deriving a + * certainty into 1 + maxRetries identical failed rows. A transport maps + * it to **422**, exactly as `'FLOW_NO_START_NODE'`: the stored + * definition cannot be executed, an authoring defect retrying cannot + * fix. Distinct from it on purpose: that one says the definition has + * nothing to dispatch, this one says a node's config contradicts the + * schema the definition itself declares. This member is the ruling's + * contract half; the engine begins stamping it when #10025's services + * half (the `execute()` catch short-circuit) lands. * - * Both are the remaining two rows of the #9378 trigger-status ruling; the - * union stays closed (the #9384 ruling), so these members were added - * deliberately, from measured need, rather than minted at a call site. - */ - code?: 'PERMISSION_DENIED' | 'INVALID_SIGNAL' | 'RUN_NOT_FOUND' | 'STORE_UNAVAILABLE' | 'RESUME_IN_PROGRESS' | 'INVALID_SCREEN_INPUT' | 'FLOW_DISABLED' | 'FLOW_NO_START_NODE'; + * `'FLOW_DISABLED'` / `'FLOW_NO_START_NODE'` are the remaining two rows of + * the #9378 trigger-status ruling; `'FLOW_INPUT_SCHEMA_INVALID'` joined + * the never-dispatched class under the #10025 ruling. The union stays + * closed (the #9384 ruling), so each member was added deliberately, from + * measured need — and by the spec seat — rather than minted at a call + * site. + */ + code?: 'PERMISSION_DENIED' | 'INVALID_SIGNAL' | 'RUN_NOT_FOUND' | 'STORE_UNAVAILABLE' | 'RESUME_IN_PROGRESS' | 'INVALID_SCREEN_INPUT' | 'FLOW_DISABLED' | 'FLOW_NO_START_NODE' | 'FLOW_INPUT_SCHEMA_INVALID'; /** * Lifecycle status. `'paused'` means the run suspended at a node (e.g. * an Approval node awaiting a human decision, ADR-0019) and can be