Skip to content

Commit f90e820

Browse files
os-zhuangclaude
andauthored
feat(spec): register FLOW_INPUT_SCHEMA_INVALID — the definition-level input-schema refusal's never-dispatched ADR-0112 code (#12611)
The contract half of the #10025 ruling (maintainer 2026-08-20, Option B taken whole): a definition-level input-schema refusal is non-retryable and receives its own ADR-0112 code as a never-dispatched exit, mirroring FLOW_DISABLED / FLOW_NO_START_NODE (#9378 classification). - spec: AutomationResult.code gains 'FLOW_INPUT_SCHEMA_INVALID', documented as a trigger-time refusal classified BEFORE dispatch (no status; transport maps it to 422 like FLOW_NO_START_NODE — unexecutable stored definition). - ledger: one ERROR_CODE_LEDGER row under @objectstack/runtime with the required TSDoc justification: never-dispatched semantics, non-retryable per the #10025 ruling, one refusal row instead of 1 + maxRetries identical failed rows; registered ahead of its producer by design (the #10413 to #10576 split shape) — the emitting half is #10025's and asserts this exact string by value. - tests: ErrorCode.parse / ledger-containment / no-synonym pins in error-code-ledger.test.ts; a compile-level union pin plus the status-absent never-dispatched shape in automation-service.test.ts. - generated: contract.mdx / error-code-ledger.mdx regenerated via gen:docs. - changeset (minor): states plainly that retry accounting and run-log volume change for affected flows once the services half lands (N identical failed rows become one). Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 146f448 commit f90e820

7 files changed

Lines changed: 118 additions & 6 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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)
6+
7+
`AutomationResult.code` gains `'FLOW_INPUT_SCHEMA_INVALID'`, and the code is
8+
registered in the ADR-0112 error-code ledger under `@objectstack/runtime`
9+
beside `FLOW_DISABLED` / `FLOW_NO_START_NODE`. Semantics: a node's static
10+
`config` violates the `inputSchema` its own flow definition declares, so the
11+
engine refuses to dispatch — nothing runs, nothing is written, the result
12+
carries the code and NO `status` (the #9378 never-dispatched class), and a
13+
transport maps it to **422** (unexecutable stored definition, exactly as
14+
`FLOW_NO_START_NODE`).
15+
16+
Ruled by #10025 (maintainer, 2026-08-20): the refusal is **non-retryable**
17+
the guard's verdict is a pure function of the flow definition, so re-running
18+
it cannot change the answer. This release ships only the contract vocabulary;
19+
the engine behaviour change is #10025's services half and lands separately.
20+
21+
**Operator-visible consequence once that services half lands, stated
22+
plainly:** retry accounting and run-log volume change for affected flows. A
23+
`strategy: 'retry'` flow whose node `config` violates its declared
24+
`inputSchema` today burns its whole retry budget (including configured
25+
backoff delays) and writes `1 + maxRetries` identical failed run-log rows;
26+
after the services half it refuses **once**, producing **one** run-log row
27+
carrying `code: 'FLOW_INPUT_SCHEMA_INVALID'` and no `status`. Anything
28+
watching retry counters or paging run history for this exit sees different
29+
numbers for the same flow.

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **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) |
30+
| **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) |
3131
| **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) |
3232
| **message** | `string` || Readable error message |
3333
| **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);
182182
* `FLOW_CONVERSION_CONFLICT`
183183
* `FLOW_DISABLED`
184184
* `FLOW_FAILED`
185+
* `FLOW_INPUT_SCHEMA_INVALID`
185186
* `FLOW_NO_START_NODE`
186187
* `FORBIDDEN`
187188
* `FORM_NOT_FOUND`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ const result = ErrorCode.parse(data);
286286
* `FLOW_CONVERSION_CONFLICT`
287287
* `FLOW_DISABLED`
288288
* `FLOW_FAILED`
289+
* `FLOW_INPUT_SCHEMA_INVALID`
289290
* `FLOW_NO_START_NODE`
290291
* `FORBIDDEN`
291292
* `FORM_NOT_FOUND`

packages/spec/src/api/error-code-ledger.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,20 @@ describe('ErrorCode (standard ∪ registered)', () => {
212212
expect(() => ErrorCode.parse('DUPLICATE')).toThrow();
213213
});
214214

215+
it('accepts the #10025 definition-level input-schema refusal code (#11504)', () => {
216+
// The ruled contract half of #10025's Option B (maintainer, 2026-08-20):
217+
// the definition-level input-schema refusal is a never-dispatched exit
218+
// with its own code. Registered AHEAD of its producer, deliberately — the
219+
// #10413 → #10576 split shape: the services half (`execute()`'s catch
220+
// short-circuit) is #10025's, blocked on this registration, and asserts
221+
// this exact string by value — so the value is pinned here by value too.
222+
expect(ErrorCode.parse('FLOW_INPUT_SCHEMA_INVALID')).toBe('FLOW_INPUT_SCHEMA_INVALID');
223+
expect(ERROR_CODE_LEDGER['@objectstack/runtime']).toContain('FLOW_INPUT_SCHEMA_INVALID');
224+
// Not a synonym of any standard member (FLOW is a token no member
225+
// carries) — registered plainly, no waiver recorded or needed.
226+
expect(standardSynonymOf('FLOW_INPUT_SCHEMA_INVALID')).toBeUndefined();
227+
});
228+
215229
it('rejects unregistered, lowercase, and numeric codes', () => {
216230
expect(() => ErrorCode.parse('TOTALLY_MADE_UP_CODE')).toThrow();
217231
expect(() => ErrorCode.parse('validation_error')).toThrow(); // pre-ADR-0112 dialect

packages/spec/src/api/error-code-ledger.zod.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,31 @@ export const ERROR_CODE_LEDGER = {
290290
// `errorFromThrown` (`action-execution.ts`). Reported by the #8087
291291
// dispatcher-vocabulary gate.
292292
'FLOW_FAILED',
293+
// [#11504] the definition-level input-schema refusal: a node's static
294+
// `config` violates the `inputSchema` its own flow definition declares, so
295+
// the engine refused to dispatch — nothing ran, nothing was written, and
296+
// the result carries NO `status` (the #9378 never-dispatched class, beside
297+
// FLOW_DISABLED / FLOW_NO_START_NODE). The guard's verdict is a pure
298+
// function of the flow definition (`validateNodeInputSchemas` reads
299+
// `node.inputSchema` against the static `node.config`; its variables
300+
// parameter is deliberately unused), so re-running it cannot change the
301+
// answer — ruled NON-RETRYABLE by #10025 (maintainer, 2026-08-20, Option B
302+
// taken whole): ONE refusal row carrying this code instead of
303+
// 1 + maxRetries identical `status: 'failed'` rows re-deriving a
304+
// certainty. Answered 422 like FLOW_NO_START_NODE — understood request,
305+
// existing flow, unexecutable definition — and deliberately distinct from
306+
// it: that one says the definition has nothing to dispatch, this one says
307+
// a node's config contradicts the schema the definition itself declares.
308+
// Not a VALIDATION_ERROR synonym: the REQUEST is well-formed — what fails
309+
// is the stored definition. Registered ahead of its producer by design
310+
// (the #10413 → #10576 split shape, applied to #10025 → #11504): the
311+
// emitting half — `execute()`'s catch short-circuiting before
312+
// `retryExecution` in `@objectstack/service-automation` — is #10025's,
313+
// blocked on this row, and asserts this exact string by value. Registered
314+
// HERE and not under the engine's package for the same reason as its
315+
// three FLOW_* siblings: the trigger door, not the producer, is where the
316+
// wire vocabulary is named.
317+
'FLOW_INPUT_SCHEMA_INVALID',
293318
// [#9415] the trigger door refused a flow whose stored definition has no
294319
// `start` node — there is nothing to dispatch, so the run never began.
295320
// Answered 422 by `respondToFlowTrigger`: understood request, existing

packages/spec/src/contracts/automation-service.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ describe('Automation Service Contract', () => {
169169
expect(flowEnabled).toBe(true);
170170
});
171171

172+
// [#11504] The #10025 ruling's contract half: a definition-level
173+
// input-schema refusal is a NEVER-DISPATCHED exit with its own
174+
// `AutomationResult.code` member. The compile of the literal below IS the
175+
// assertion — the #9384 reverse verification run forward: before the union
176+
// widened, this exact string was a type error.
177+
it('should accept FLOW_INPUT_SCHEMA_INVALID as a never-dispatched trigger refusal', async () => {
178+
const service: IAutomationService = {
179+
execute: async (): Promise<AutomationResult> => ({
180+
success: false,
181+
code: 'FLOW_INPUT_SCHEMA_INVALID',
182+
error: "Node 'sync' config violates its declared inputSchema",
183+
}),
184+
listFlows: async () => ['guarded_flow'],
185+
};
186+
187+
const result = await service.execute('guarded_flow');
188+
expect(result.success).toBe(false);
189+
expect(result.code).toBe('FLOW_INPUT_SCHEMA_INVALID');
190+
// Never dispatched ⇒ no lifecycle verdict, matching FLOW_DISABLED /
191+
// FLOW_NO_START_NODE (#9378): `status` absent is exactly what separates a
192+
// refused dispatch from a run that dispatched and failed.
193+
expect(result.status).toBeUndefined();
194+
});
195+
172196
// [#4127] `getConnectorDescriptors` is the sibling of `getActionDescriptors`
173197
// — the other half of the flow designer's `connector_action` pickers — and
174198
// was the last of the four dispatcher routes calling a method the contract

packages/spec/src/contracts/automation-service.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,30 @@ export interface AutomationResult {
248248
* retrying. Distinct from `'FLOW_DISABLED'` on purpose: one is a
249249
* reversible operational state, the other a malformed definition, and
250250
* collapsing them tells an operator to flip a switch that will not help.
251+
* - `'FLOW_INPUT_SCHEMA_INVALID'` — a node's static `config` violates the
252+
* `inputSchema` its own flow definition declares, so the
253+
* definition-level guard refused to dispatch. The verdict is a pure
254+
* function of the flow definition — re-running the guard cannot produce
255+
* a different answer — so the refusal is NON-RETRYABLE (#10025,
256+
* maintainer ruling 2026-08-20: Option B taken whole): the engine
257+
* refuses once instead of burning the whole retry budget re-deriving a
258+
* certainty into 1 + maxRetries identical failed rows. A transport maps
259+
* it to **422**, exactly as `'FLOW_NO_START_NODE'`: the stored
260+
* definition cannot be executed, an authoring defect retrying cannot
261+
* fix. Distinct from it on purpose: that one says the definition has
262+
* nothing to dispatch, this one says a node's config contradicts the
263+
* schema the definition itself declares. This member is the ruling's
264+
* contract half; the engine begins stamping it when #10025's services
265+
* half (the `execute()` catch short-circuit) lands.
251266
*
252-
* Both are the remaining two rows of the #9378 trigger-status ruling; the
253-
* union stays closed (the #9384 ruling), so these members were added
254-
* deliberately, from measured need, rather than minted at a call site.
255-
*/
256-
code?: 'PERMISSION_DENIED' | 'INVALID_SIGNAL' | 'RUN_NOT_FOUND' | 'STORE_UNAVAILABLE' | 'RESUME_IN_PROGRESS' | 'INVALID_SCREEN_INPUT' | 'FLOW_DISABLED' | 'FLOW_NO_START_NODE';
267+
* `'FLOW_DISABLED'` / `'FLOW_NO_START_NODE'` are the remaining two rows of
268+
* the #9378 trigger-status ruling; `'FLOW_INPUT_SCHEMA_INVALID'` joined
269+
* the never-dispatched class under the #10025 ruling. The union stays
270+
* closed (the #9384 ruling), so each member was added deliberately, from
271+
* measured need — and by the spec seat — rather than minted at a call
272+
* site.
273+
*/
274+
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';
257275
/**
258276
* Lifecycle status. `'paused'` means the run suspended at a node (e.g.
259277
* an Approval node awaiting a human decision, ADR-0019) and can be

0 commit comments

Comments
 (0)