Skip to content

[Decision] AutomationResult.code is a CLOSED union that cannot carry FLOW_FAILED — #8684's ruled producer-first shape landed without the spec edit #9384

Description

@os-zhuang

Filed by the domain:cli PM seat (#6024), session session_012WKSnqAaoqtW3QX7SSf1Vk, under the standing rule that a ruling's mandated action which measures a contrary fact during implementation becomes a decision card — and under #8684's own execution note: 「if any part drifts into packages/spec, that fragment goes to the spec seat」.

Not a defect and not a blocker. PR #9379 is complete and correct on the wire. This card exists so a literal line of a maintainer ruling that could not be executed is on the record as a decision rather than as a silent deviation. PR #9379 is deliberately NOT enqueued while this is open — that is the objection window, not a doubt about the code.

What the ruling said, and what was measurable

#8684's maintainer ruling (2026-08-15, comment 5302930352), sub-decision 1:

Width = A, producer-first. The engine's four code-less exits get real codes in service-automation (FLOW_FAILED for ran-and-failed / subflow-failed; a not-found/stale-suspension code ⇒ 404 for the other two), and packages/runtime/src/domains/automation.ts extends its existing six-code map.

Half of that is impossible as written. AutomationResult.code is a closed union declared in packages/spec/src/contracts/automation-service.ts:223:

code?: 'PERMISSION_DENIED'|'INVALID_SIGNAL'|'RUN_NOT_FOUND'|'STORE_UNAVAILABLE'|'RESUME_IN_PROGRESS'|'INVALID_SCREEN_INPUT';

Naming FLOW_FAILED in the engine requires widening it — a packages/spec edit the same ruling routes away from this card. The dev measured rather than assumed, with reverse verification (direction predicted before running): applying code: 'FLOW_FAILED' to the engine's ran-and-failed exit moved tsc --noEmit on service-automation from 3 errors to 4, the new one being

src/engine.ts(4085,21): error TS2322: Type '"FLOW_FAILED"' is not assignable to type
'"PERMISSION_DENIED" | "INVALID_SIGNAL" | "RUN_NOT_FOUND" | "STORE_UNAVAILABLE" | "RESUME_IN_PROGRESS" | "INVALID_SCREEN_INPUT" | undefined'.

I re-verified the union on origin/main myself before filing. The dev obeyed the spec-escape clause over the literal sentence, delivered the ruling's substance without the spec edit, and reported it instead of resolving it. That is the correct order of operations.

What landed instead, and why the wire is unchanged from the ruling's own table

  • The engine still classifies — the two never-dispatched exits (flow deregistered under a live pause; suspended node edited away) now carry the existingRUN_NOT_FOUND, so a transport answers them 404.
  • The route names FLOW_FAILED on the remaining success: false, answering 400.
  • The route never sniffs. I verified this independently rather than taking it on report: enumerating every success: false return in resumeInternal on the branch, the only two that are still code-less are the ran-and-failed exit and the subflow-failed exit — both the ruling's "ran, rejected" row. The fallthrough is therefore provably one class, not a heuristic. The summary / durationMs sniffing that PD Add comprehensive test suite for Zod schema validation #12 forbids appears nowhere.

So the deviation is where the string is stamped, not what goes on the wire.

The question

Does the spec seat want the union widened?

  • A — leave as landed. No spec change. Engine classifies via RUN_NOT_FOUND; the route names FLOW_FAILED, which is also where ADR-0112's ledger registers that code (@objectstack/runtime) and where the precedent producer this card inherits (packages/runtime/src/action-execution.ts) already stamps it.
  • B — widen the union to add FLOW_FAILED, then the engine names the ran-and-failed class directly and the route gains one more coded arm. Measured cost: one union member plus TSDoc; changes no generated artifact (the api-surface snapshot records only AutomationResult (interface), with no member detail).
  • C — B plus a distinct stale-suspension code (e.g. FLOW_DEFINITION_GONE) so the two 404 causes are separable by code as well as by message.

Four facets

  • Real business need — nothing branches on either addition today. objectui's interpretFlowResponse treats 404 as terminal by status, without reading the code, and the engine's error already names which of the three causes it was, verbatim on the wire. B buys no behaviour; C invents vocabulary no consumer reads. → A
  • Long-term platform soundness — B is superficially tidier (classification wholly inside the component that knows it), but the ADR-0112 ledger assigns FLOW_FAILED to @objectstack/runtime, and stamping it inside service-automation would make a second package emit a runtime-owned wire code — a second spec fragment (ledger provenance) on top of the union widening. A is the shape already consistent with the ledger. → A
  • Making AI-written code hard to get wrong — the failure mode automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 is about (a code-less success:false reaching a fallthrough) is already removed by the producer half. A fallthrough that is provably a single class is not the tolerant-consumer shape; a sniffing one would have been, and no sniffing exists. → A
  • Not growing scope at this stage — B is a refactor with zero behaviour change, i.e. schedulable, not a gap. C is scope growth outright. → A

Recommendation, and why it is not being self-adjudicated

A, with B available as an optional tidy-up whenever the spec seat is in that file anyway.

All four facets align, which is normally the delegated-adjudication condition — but three separate rules keep this on the maintainer's desk, and any one of them would be enough: adjudication authority belongs solely to the triage seat and this is an execution seat; the subject is a public contract union, which is the protocol-change class on the manual floor; and the answer would touch the literal text of a standing maintainer ruling. Recorded, not decided.

Refs

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