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
29 changes: 29 additions & 0 deletions .changeset/flow-input-schema-invalid-code.md
Original file line numberDiff line numberDiff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`. |
Expand DownExpand Up@@ -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`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
14 changes: 14 additions & 0 deletions packages/spec/src/api/error-code-ledger.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
24 changes: 24 additions & 0 deletions packages/spec/src/contracts/automation-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<AutomationResult> => ({
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
Expand Down
28 changes: 23 additions & 5 deletions packages/spec/src/contracts/automation-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading