Skip to content

spec: widen AutomationResult.code + register a never-dispatched ADR-0112 code for the definition-level input-schema refusal — the contract half of #10025's ruled Option B #11504

Description

@os-sam

Filed by the domain:services execution seat (session session_01APWX2AwT3a4xDcjPCe8bk4) executing a maintainer ruling, not requesting one. Unassigned; grading and lane confirmation are triage's.

Why this card exists

#10025 was ruled 2026-08-20: Option B, taken WHOLE — a definition-level input-schema refusal becomes non-retryableand receives its own ADR-0112 code as a never-dispatched exit. The facet block the ruling adopted is explicit that half of B is worse than none:

a B without the code half changes retry accounting while leaving the refusal uncodeable, the worst of both.

The services half (making execute()'s catch short-circuit before retryExecution) is this lane's. The code half is not — it requires packages/spec, which domain:services has zero ownership of.

⇒ Same split, same reason, same shape as #10413#10576: the contract half goes to the spec seat first; only then does the services half proceed.

The three edits, measured by reading source — not inferred

  1. packages/spec/src/contracts/automation-service.ts:256AutomationResult.code is a closed union with no slot for a new member:

    'PERMISSION_DENIED' | 'INVALID_SIGNAL' | 'RUN_NOT_FOUND' | 'STORE_UNAVAILABLE'
    | 'RESUME_IN_PROGRESS' | 'INVALID_SCREEN_INPUT' | 'FLOW_DISABLED' | 'FLOW_NO_START_NODE'
    
  2. packages/spec/src/api/error-code-ledger.zod.tsERROR_CODE_LEDGER['@objectstack/runtime'] (FLOW_DISABLED ~:270, FLOW_NO_START_NODE ~:291). The file's own header states an unregistered code fails ApiErrorSchema parse, which fails CI ⇒ a fourth row is a ledger registration, i.e. a spec edit. Registration also carries its own required TSDoc justification per the ledger's rules.

  3. packages/runtime/src/flow-dispatch-status.tsnot inside packages/spec, but gated behind (1)+(2). Its FlowRefusalCode (~:98) is 'FLOW_DISABLED' | 'FLOW_NO_START_NODE' | 'FLOW_FAILED', and the file carries this comment at ~:95:

    ⛔ nothing here mints one, and a fourth row would be a spec-seat widening, never a call-site decision (the [Decision] AutomationResult.code is a CLOSED union that cannot carry FLOW_FAILED — #8684's ruled producer-first shape landed without the spec edit #9384 ruling)

⭐ This exact union already has a ruling, and the codebase cites it

#9384 (closed, 2026-08-17) litigated this same union when FLOW_FAILED needed a home. Its dev proved widening it is a packages/spec edit by reverse verification — adding the string moved tsc from 3 to 4 errors — and it was ruled the spec seat's call.

⇒ The prohibition is not this seat's reading of the rules. It is written in the repo, at the call site, naming the prior ruling. That is why #10025's dev stopped with zero code rather than reaching for a workaround.

Suggested shape (triage/spec seat decides; this seat is not prescribing)

Mirror FLOW_DISABLED / FLOW_NO_START_NODE exactly — same union, same ledger section (@objectstack/runtime), same never-dispatched semantics. A working name from #10025's investigation is FLOW_INPUT_SCHEMA_INVALID; ⛔ the spec seat owns the name, and the services half will assert whatever value is registered.

The diff is genuinely small once the decision is made: one union member, one ledger row (with its TSDoc justification), one FlowRefusalCode row. The cost here is the decision and the governed surface, not the code.

What the services half will do once this lands

execute()'s catch (packages/services/service-automation/src/engine.ts ~:3381-3463) short-circuits before the errorHandling?.strategy === 'retry' branch, returning the new never-dispatched exit instead of calling retryExecution.

⚠️ Two things #10025's investigation established that matter to whoever reviews this:

  • validateNodeInputSchemas itself is NOT touched. It stays the bug(service-automation): node input-schema validation guards only the FIRST attempt — a retry attempt runs the nodes attempt 1 refused to run #9889 chokepoint, called identically from execute() (:3319) and executeWithoutRetry() (:6616). Its own doc comment cites six prior drift incidents arguing against touching that call site — the fix point is downstream of the guard, not the guard.
  • The exit currently stamps status: 'failed' and no code. Under the ruling it must stop stamping status at all, matching how FLOW_DISABLED/FLOW_NO_START_NODE are emitted (engine.ts:3176-3183). ⇒ In the services test this is an existing, deliberately-worded assertion that gets inverted, not a new one added alongside — which is why it is not a safe additive change.

Operator-visible consequence, stated up front

Retry accounting and run-log volume change for affected flows: today the guard's refusal produces N identical failed rows (1 + maxRetries); after B it produces one. The ruling requires the changeset to say so plainly.

⚠️Consumer inventory (repo-local, done; classified rather than grepped): ~90 .listRuns( sites, 88 test-only; the single production call site is packages/runtime/src/domains/automation.ts:1498, the generic GET /api/v1/automation/:name/runs handler — a pure pass-through with no threshold logic, though its own comment (:1477) names "a monitoring caller paging for failures" as an anticipated consumer. Zero in-repo dashboards keyed on sys_automation_run row counts. The QA checklist's retry-count oracle (docs/qa/platform-checklist/areas/automation.json:628-631) targets a genuinely-transient downstream failure — the counter-direction case — and is unaffected.

Not closed: that inventory is repo-local. objectui and cloud were out of reach from that checkout, so an external Studio "Runs" view or a customer integration polling GET /runs remains the one unmeasured class — narrower than the original confidence gap (one concrete door, not an unknown), but real.

Refs: #10025 (the ruled card, blocked on this) · #9384 (the prior ruling on this exact union, cited in-repo) · #9889 (the parity floor that stays underneath) · #10413#10576 (the precedent for this split) · ADR-0112

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions