Uh oh!
There was an error while loading. Please reload this page.
fix(automation): answer real HTTP status codes on the run-resume route - #9379
Conversation
The resume route answered HTTP 200 wrapping an inner `{success:false}` for a
run that resumed and then failed, so a caller branching on the HTTP status
alone read a failed run as a successful one. Applies the `/actions`
status-code unification to this route.
Producer-first: the engine's two never-dispatched exits (the flow
deregistered, the suspended node edited away) now carry `RUN_NOT_FOUND`, so
the route answers them 404 instead of mislabelling them a business rejection.
What reaches the route's terminal-failure arm has consumed its pause and run,
and answers 400 `FLOW_FAILED` — carrying the flow author's `errorMessage` and
the run summary in `error.details`, the one location the console reads them
from. The route never sniffs the result shape to tell the two classes apart.
Co-Authored-By: Claude <noreply@anthropic.com>… launch-window lockstep rule Every publishable package is in one Changesets fixed group, so a single major promotes the whole monorepo; check-changeset-no-major prescribes minor for a breaking change during the launch window. The body keeps the BREAKING framing. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-zhuang
commented
Aug 17, 2026
⛔ Do not enqueue this PR yet — and the reason is not the code. The implementation is complete and, on the readings I have taken so far, correct. It is held open deliberately as an objection window, under the standing rule that when a ruling's mandated action measures a contrary fact during implementation, the conflict becomes a decision card and the PR does not arm auto-merge until it is answered. The conflict:#8684's ruling, sub-decision 1, says the engine names Filed as #9384 for the spec seat / maintainer. The deviation is where the string is stamped, not what goes on the wire: the ruling's own status table is reproduced exactly. What I verified myself rather than taking on report (the dev's readings are good; these are the two that decide whether the shape is sound):
Two things I am not deciding and have escalated instead:
CI at the time of writing: 8 of the 5 required check families complete and green ( Generated by Claude Code |
os-zhuang
commented
Aug 17, 2026
Correction to my own landing criterion above — the required check set is SIX, not five. PM My ACCEPT comment said "all five required check families are green" and named The verdict on this PR is unchanged — I re-checked head Correcting it anyway because the rule was wrong, not just the count: I had been treating that check as advisory in every landing decision this session, and an advisory check is one this seat is explicitly required not to treat as a gate. A red While reading the ruleset, two further facts worth having on the record for anyone landing in this lane: ⛔ This PR remains held on #9384, unchanged. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8684
The automation run-resume route answered
HTTP 200wrapping an inner{success: false}for a run that resumed and then failed. #3962 already unified
/actionson real statuscodes; this route was left out, so a scripted or integration caller that branches on the
HTTP status alone read a failed run as a successful one.
Implemented per the maintainer ruling recorded on the card (comment
5302930352,2026-08-15) and the cross-repo contract handoff from objectui#4784 / PR #4899.
What changes
Producer first —
packages/services/service-automation/src/engine.ts. The twonever-dispatched exits on the resume path now carry a classification instead of being
code-less: the flow deregistered under a live pause, and the suspended node edited away.
Both mean the suspension is real but can never continue — nothing ran — so they are the
same terminal class as a missing suspension and answer 404.
Route stays a pure mapper —
packages/runtime/src/domains/automation.ts. One new armunder the existing six-code map: a
success: falseresult that reached it consumed itspause and ran, so it answers 400
FLOW_FAILED. The route never inspectssummaryordurationMsto tell the two classes apart — that sniffing is the tolerant-consumer shapePD #12 forbids, and it is unnecessary because the engine now says which is which.
404(RUN_NOT_FOUND)400FLOW_FAILEDPERMISSION_DENIED·INVALID_SIGNAL·INVALID_SCREEN_INPUT·RUN_NOT_FOUND·STORE_UNAVAILABLE·RESUME_IN_PROGRESSThe consumer contract is carried, not dropped. The ADR-0112 envelope has no
data, sothe flow author's own
errorMessagetravels inerror.details.errorMessage— the onedocumented location the console reads (objectui
flowResponse.ts, PR #4899) — and the rawnode failure stays
error.message. The run's per-nodesummaryrides indetailsfor thesame reason it was on the 200 body: it is how a caller finds which node failed.
The named anti-pattern was re-verified and deliberately not reproduced: the live
FLOW_FAILEDproducer atpackages/runtime/src/action-execution.ts:607-615builds itsmessage from
result.errorand dropsresult.errorMessageentirely.INVALID_SIGNAL/INVALID_SCREEN_INPUTkeep their own codes and stay retryable — theengine refuses those before consuming the suspension, and only
FLOW_FAILEDis terminalconsumer-side.
@objectstack/clientneeded no code change and this is a measured finding, not anomission:
_fetchalready throws on every non-2xx beforeunwrapResponseruns, soautomation.resume()now rejects witherr.code === 'FLOW_FAILED',err.httpStatus,and
err.details.errorMessagerather than resolving with an inner failure. Bothresumesurfaces document the new contract and both are pinned by tests.
Two rulings collided on one file, and the collision is reported rather than resolved
The ruling says the four code-less exits get real codes in the engine,
FLOW_FAILEDamongthem (sub-decision 1) — and it also says any fragment that drifts into
packages/specbelongs to the spec seat. Those cannot both hold here.
AutomationResult.codeis a closed union declared inpackages/spec/src/contracts/automation-service.ts, so namingFLOW_FAILEDin the enginerequires widening it. Measured, not assumed — with
code: 'FLOW_FAILED'on theran-and-failed exit,
tscgoes from 3 pre-existing errors to 4:So this PR keeps
packages/specuntouched and delivers the ruling's substance — theproducer classifies, the transport maps, no sniffing — by naming
FLOW_FAILEDat theroute. That is also where it belongs on the ledger's own terms: ADR-0112 registers
FLOW_FAILEDto@objectstack/runtime, and the precedent producer this card inherits(
action-execution.ts) stamps it in runtime too. Stamping it insideservice-automationwould have made a second package emit a runtime-owned wire code — asecond spec fragment (a ledger provenance entry) on top of the union widening.
For the spec seat, one line: adding
'FLOW_FAILED'to that union would let the enginename the ran-and-failed class directly. It changes no generated artifact — the api-surface
snapshot records
AutomationResult (interface)with no member detail.The two open questions the dispatch left open, answered
The 404 code reuses
RUN_NOT_FOUNDrather than introducing a distinct one. The remedyis identical (this pause is finished; start a new run), the consumer branches on the 404
status and not on the code, and the engine's message still names which of the three
causes it was, verbatim on the wire. A distinct code would also require widening the same
closed union — vocabulary nothing reads, at the cost of the spec fragment above. If a
caller ever needs to branch on the difference, add it then.
The engine's
success: falseexits reachable fromresume()were enumerated in full(the ruling named four; this confirms the set is complete):
RESUME_IN_PROGRESS,STORE_UNAVAILABLE,RUN_NOT_FOUND,PERMISSION_DENIED,INVALID_SIGNAL,INVALID_SCREEN_INPUT, the two stale-suspension exits, the subflow-failed exit and theran-and-failed exit. Every one is now answered by a named arm; nothing rides HTTP 200.
Scope held
handling is not read, not tightened, not moved. The two halves separated cleanly; the
sequencing behind this card is not falsified.
triggerroutes carry the identical defect and were left alone — filedseparately rather than fixed here (see below). Classifying
execute()'s own code-lessexits (flow not found / flow disabled / no start node) is a design decision, not the
mechanical application of this one, and it is a far wider break.
content/docs/releases/untouched; the changeset is this PR's only release input.Verification
Union of gates run at
4af8ef9fc(the final commit):check:cross-package-test-inputs,check:route-envelope,check:test-source-alias,check:type-source-resolution,scripts/check-cross-package-test-inputs.mjs,scripts/docs-audit/check-affected-docs.mjs,check:query-options-erasure,check:type-check-coverage,check:engine-double-contract,check:where-matcher,check:nul-bytes, andcheck:type-check-debton a fully builtworkspace closure (
--re-measure: 33 ledger entries, none above its recorded number),plus the changeset family the re-derivation added
that the dispatch had not named —
check:changeset-gate-self-tests,check:objectui-changeset,check-changeset-no-major.mjs,check-empty-changeset.mjs,check-adr-0087-registration.mjs. All green.Tests:
@objectstack/runtime165 files / 2467 tests,@objectstack/service-automation80 files / 969 tests,
@objectstack/client23 files / 303 tests — all passing.typecheckgreen forruntimeandclient;service-automationhas notypecheckscript and its 3 ledgered errors (TS2341 in a file this PR does not touch) are unchanged.
Two gate outcomes worth a reviewer's eye, both resolved by following the gate rather than
around it:
check-changeset-no-majorrefused themajorbump. Every publishable package is inone Changesets fixed group, so a single
majorpromotes the whole monorepo; the gate'sown remedy during the launch window is to ship a breaking change as
minor. The bump isminor, the body keeps the BREAKING framing. Adding theallow-majorlabel insteadwould have been a release-versioning decision, which is not this PR's to take.
check-adr-0087-registrationrequired a disposition in writing. Answerednot-required (no-migration-prescription): this retires no metadata surface — no Zodschema, no authorable key, no stored row changes shape — so
objectstack migrate metahas nothing to rewrite and no ledger entry can honestly be written. The consumer change
is HTTP/SDK-level and this changeset is the channel that carries it. Overrule it if you
read the category differently.
Out-of-scope findings filed
triggerroutes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378 — both automation trigger routes (POST /:name/triggerandthe legacy
POST /trigger/:name) answerdeps.success(result)unconditionally, so aflow that ran and failed rides HTTP 200 inside the same double envelope this PR just
closed on resume. Same defect class, different engine path, materially wider break.
Generated by Claude Code