Repository navigation
fix(runtime): /actions answers the #9378 flow-dispatch status table, from one shared definition - #9584
Conversation
…9446) `dispatchFlowAction` mapped every `success: false` automation result to `400 FLOW_FAILED` under a comment asserting "The flow RAN and rejected" — false for two of the four exits it caught. A disabled flow invoked through an action told the caller a run had failed when no node ever executed, and the producer's own `result.code` was available and ignored. The table now lives in one module, `flow-dispatch-status.ts`, read by both the `/actions` door and the trigger door: 404 (flow not found) / 409 FLOW_DISABLED / 422 FLOW_NO_START_NODE / 400 FLOW_FAILED. Maintainer ruling 2026-08-18, verbatim 「同意」: the table is a property of the flow-dispatch contract, not of the trigger route. Co-Authored-By: Claude <noreply@anthropic.com>
…dd the changeset (#9446) `ui/actions.mdx` documented the divergence this change closes; it now states the converged table. `automation/flows.mdx` gains the cross-door note, and `http-protocol.mdx`'s declared-endpoint row points at #9462 — the door that is still unconverged — instead of at this card, which would read as "fixed" once this closes. Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not seeCoarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c51a82aff612af37e80ac68e0336716304b055c6 && git checkout c51a82aff612af37e80ac68e0336716304b055c6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7e202882092bf460b0045f2be9a281797a849462 41ff10ed751228be62eb5fca6b72ce292420ec60 && git checkout -B drift-repro 7e202882092bf460b0045f2be9a281797a849462 && git merge --no-ff 41ff10ed751228be62eb5fca6b72ce292420ec60
node scripts/docs-audit/affected-docs.mjs --json 7e202882092bf460b0045f2be9a281797a849462
|
…ter (objectstack-ai#9739) Since objectstack-ai#9446 (PR objectstack-ai#9584), POST /api/v1/actions/:object/:action also emits both codes through dispatchFlowAction (packages/runtime/src/action-execution.ts), not just the trigger door's respondToFlowTrigger. Update the ledger's provenance notes to record the second emitter, following the in-file PACKAGE_DELETE_PARTIAL precedent ("Second EMITTER of the code ... Provenance, not identity"). Comment-only: the registration rows themselves are unchanged, so no code is registered or unregistered. Fixes objectstack-ai#9586 Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw Co-authored-by: Claude <noreply@anthropic.com>
…rsession (objectstack-ai#18859) Fixes objectstack-ai#18828 Clause-②: no The claim protocol forbids a second `Claim:` — the rule is this file's own, in the objectstack-ai#17366 docblock at `scripts/pm/check-clause2-carriers.mjs:378` — and until this PR nothing READ it. A thread that carried the forbidden second line was RANKED, not refused: the governing-claim selector took the newest live claim that parses a branch and printed the loser as `rejected: 1 … a SUPERSEDED claim`, clean and green at exit 0, in the register reserved for a transition the protocol DESIGNED. A writer-side prohibition with no enforcing reader. `claimRepeats` and the C8 row are that reader.⚠️ **This is a RESUMED delivery.** Three commits were already on this branch from a run whose container was killed in its final-gates phase. Nothing it wrote was rewritten and nothing was redone — every item of the dispatch contract was re-verified against the tree, and all three readings were re-taken at the current tip and are dated below. The verified/fixed ledger is the first section. ## What was VERIFIED and what was FIXED Every contract item was found already correct and is left **byte-unchanged**. No code fix was needed; the only commit this run adds is a merge of `origin/main` (the derivation was answering about a stale tree — see Gates). | contract item | finding | |:---|:---| | the reading is a VERDICT at `EXIT_PAIR_ADVERSE` (4), never a NOTE at 0 | **verified** — `C8` is pushed in `pairRows` (:4430); `pairNotes` does not carry it, pinned | | `claimCarrierSelection` stays a pure function of the rows it is handed | **verified** — byte-identical to `origin/main` (sha256 of the whole function `40f59ba86082c358` at both revs) | | `CLAIM_COMMENT_MARKER` unwidened | **verified** — it is *imported* from `check-half-states.mjs` and read through `markerMatches`; that file is not in this diff at all | | `CLAIM_SELECTION_RULE` and the governing-claim choice unchanged | **verified** — byte-identical across revs; every hunk but two is a pure insertion | | SUPERSEDED / RETRACTED wordings byte-unchanged where they still apply | **verified** — no hunk touches them; the fixture control below prints the SUPERSEDED sentence identically at both revs | | every pin (a)–(i) present, each with a non-vacuity control | **verified** — 52 `t(...)` cases in the battery block; the ablation shows all nine directions carried | | battery registered in the roster with its case count | **verified** — :792, pinned at 52, and the block declares exactly 52 | | `SELF_TEST_BATTERY_FLOOR` raised by exactly one | **verified** — 31 → 32 (:806) | | the live census extended for this shape, population named | **verified** — the five measured instances replayed from their real rows, with `objectstack-ai#18559` as the sanctioned-shape control | `rowAuthor`, `laterOnThread` and `claimRetractions` are byte-identical across the two revs as well. ## The before-reading — the fixture control through the exported reader The same two rows (one author, two claims each parsing a branch, no retraction between) through the same exported `claimCarrierSelection` / `pairInputRecord` / `pairRows`, at `origin/main` `88aa326deb` and at this branch: | | `origin/main` `88aa326deb` | this branch | |:---|:---|:---| | `claims` / `live` / `pool` / `rejected` | 2 / 2 / 1 / 1 | 2 / 2 / 1 / 1 — **unmoved** | | governing claim | `7100000002` | `7100000002` — **unmoved** | | `rejected[0].reason` | `a SUPERSEDED claim — it is not the newest LIVE claim that parses a branch …` | byte-identical | | `claimRepeats` exported | **no — the reader does not exist at that rev** | yes | | `claim.repeat` in the record | absent | `1 author(s) holding more than one LIVE claim comment …` | | `pairRows` codes | *(none)* | `C8` | | **verdict** | **exit 0 — nothing refused** | **exit 4 (`EXIT_PAIR_ADVERSE`)** | That is the defect and the repair in one table: the selector does not move, and the thread stops reading green. ## The live count — the five cards and the control, re-taken 2026-09-18T00:44:39Z Read per card through the gate's own `markerMatches` / `CLAIM_COMMENT_MARKER` and `claimRetractions`, not by eye.⚠️ Those threads may have left this state since. | card | `Claim:` comments (author) | `Clause-②-correction:` | `Release:` | the OLD reading | C8 today | card state | open delivering PR | |:---|:---|:---|:---|:---|:---|:---|:---| | objectstack-ai#18540 | 2 — `os-support-ai` (5719079496, 5720020876) | 0 | 0 | SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** | | objectstack-ai#18677 | 2 — `os-support-ai` (5720104138, 5720190458) | 0 | 0 | SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** | | objectstack-ai#18748 | 2 — `os-support-ai` (5720212595, 5720888122) | 0 | 0 | SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** | | objectstack-ai#18651 | 2 — `os-support-ai` (5721424769, 5721997887) | 0 | 0 | SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** | | objectstack-ai#18778 | 2 — `os-support-ai` (5721425530, 5722028692) | 0 | 0 | SUPERSEDED, exit 0 | **named, exit 4** | closed | **none** | | objectstack-ai#18559 *(control)* | 1 — `os-support-ai` (5721425131) | **1** (5721779100) | 0 | *(nothing rejected)* | silent | closed | **none** | The card's table reproduces exactly. All six cards are now **closed**, and the open-PR column is empty for every one of them: the only open PR cross-referenced from any of these threads is objectstack-ai#18857, whose body's closing keyword names `objectstack-ai#18780` instead — `prDeliversCard` answers `false` for all six and `true` for `objectstack-ai#18780` (the control leg), so it is not paired with any of them. ⛔ The repair of the five is `os-support-ai`'s; this PR only names them, and posts nothing on those cards. ## The escalation probe — the triage's p1 condition, MEASURED The triage marked this p2 because all five instances were one seat self-superseding, and named the escalation condition it had not run: a second `Claim:` from a DIFFERENT session on one card, which would be a silent ownership transfer printed green. I ran it. **Population, read 2026-09-18T00:47:37Z → 00:48:42Z:** every open card on both boards this gate reads — **529 open cards in `objectstack-ai/objectstack`, 413 in `objectstack-ai/objectui` (942 total)**, of which **880** carry at least one comment and were read; **163** carry at least one claim comment. 9 comment lists sit at the 100-comment cap and are UNJUDGED past it, exactly as objectstack-ai#18683 prescribes. 0 parse failures. **The answer is not 0 — it is 12.** Twelve open cards carry LIVE `Claim:` comments from two or more DIFFERENT authors with no retraction between them: | repo | card | authors holding live claims | |:---|:---|:---| | objectstack | `objectstack-ai#13503` | `claude[bot]` + `baozhoutao` | | objectstack | `#14026` | `hotlong` + `claude[bot]` | | objectstack | `objectstack-ai#15811` | `os-bill` + `os-litant` | | objectstack | `objectstack-ai#17852` | `os-warren` + `os-litant` | | objectui | `objectstack-ai#4730` | `yinlianghui` + `os-sales` | | objectui | `objectstack-ai#7070` | `os-warren` + `claude[bot]` | | objectui | `objectstack-ai#7696` | `os-justin` + `os-tesla` | | objectui | `objectstack-ai#7804` | `os-tesla` + `os-sam` + `os-justin` | | objectui | `objectstack-ai#7848` | `claude[bot]` + `baozhoutao` | | objectui | `objectstack-ai#7924` | `os-warren` + `os-sales` | | objectui | `objectstack-ai#8115` | `claude[bot]` + `yinlianghui` | | objectui | `objectstack-ai#9370` | `os-tesla` + `os-justin` |⚠️ **This PR is deliberately SILENT on all twelve** — and that silence is pinned, per direction (b). Refusing an ownership transfer between sessions is not this card's to do: it is a different state, it would need its own remedy sentence, and a row that answered both would make one sentence out of two states. This is reported here and in the dispatch report so the seat can file it; ⛔ it is not folded in. ## Who the new exit 4 meets before it lands The seat needs this before landing, so I swept it rather than assuming. Two facts: 1. **No CI job turns red.** `check:pm-clause2-carriers` — the only wiring, `.github/workflows/lint.yml:1140` — runs `--self-test` and nothing else. No workflow runs `--pair` or a sweep, so landing this changes no required context. The exit 4 appears only when a seat runs `--pair` or a sweep by hand. 2. **On the objectstack board: nobody.** Of 32 open PRs in objectstack, **none** delivers a card that would newly earn a C8. Twenty open cards across both boards would earn the row (report-only, listed in the dispatch report), but only one is reachable through an open PR, and it is in the sibling repo: **`objectstack-ai/objectui#9584`** (open, not draft; its closing keyword names `objectui#9499`), which delivers a card carrying two live claims by `os-try-charles` (5663366106 on 2026-09-14, 5690579598 on 2026-09-16). That pair answers exit 4 at its next `--pair`. `DEFAULT_SWEEP_REPO` is `objectstack-ai/objectstack`, so objectui is only ever read when passed explicitly. ⛔ Nothing was posted on objectstack-ai#9499, objectstack-ai#9584 or any of the twelve. ## The reading, and WHERE it is computed `claimRepeats` (:1898) is a **sibling pure reader beside `claimRetractions`, built on it** — the same map decides membership here and for governance, so the pool and this row cannot describe two different retractions. It names every author holding more than one LIVE claim comment, orders them by the file's one recency rule (`laterOnThread`, to ORDER the record, never to pick a winner), and resolves no state, no row and no exit code. `c8SecondClaimSameSeat` (:4380) renders the verdict; `pairRows` (:4430) pushes it as row `C8`; `pairInputRecord` adds `claim.repeat` (:5876, declared in `INPUT_RECORD_PAIR_FIELDS` at :5641) as the READING — one derivation feeding both, so the record and the verdict cannot disagree about how many claims a seat holds or which they are. **MEMBERSHIP first, and that is what makes the state repairable.** The state is read over LIVE claims only. A re-claim after a `Release:` is the protocol working and reads exactly as it did before. And a seat that already wrote a second claim has an act that clears the row: `Release:` what it holds, then one fresh `Claim:`. A rule written over the writing *moment* instead ("no retraction strictly BETWEEN the two lines") would have been unrepairable by construction — nothing un-writes a comment — so the row would have been a permanent red with a remedy nobody could execute. ### The four axes - **实际业务需求** — measured, not assumed. Five live instances on the objectstack board at filing, re-confirmed today, every one of them read green before this row; plus 20 open cards across both boards that carry the state now. The first signal in five occurrences came from a dev reading a docblock, not from any instrument. This is a real shape occurring repeatedly, not a speculative surface. - **项目长远合理性** — contract-first, and no workaround. The rule already existed in writing at :378; this adds the reader that enforces it, in the same file, over the same thread, through the same membership derivation governance uses. No new exit code was minted, no second selector, no second reader of the marker. The prohibition and its reader now live one screen apart. - **防 AI 写代码犯错** — this is the axis that decides the exit. A second `Claim:` re-enters the pool as the newest claim and becomes what every downstream reader is handed — the property the correction key was deliberately designed NOT to have. Rendering that as a NOTE at exit 0 is precisely the tolerant-consumer shape this repo refuses: an adverse fact printed green is how a batch of identical mistakes stays invisible. Declaring the prohibition and not enforcing it is the "声明而未兑现" gap; the repair is to enforce it loudly, at `EXIT_PAIR_ADVERSE`. The row also ⛔ never prescribes WHICH repair — choosing between a correction and a release would be choosing whether the card is being re-taken, which is the seat's judgement, so it names both and writes nothing. - **创业阶段不扩散需求** — the surface added is one file, one pure reader, one row, one record field. It refuses exactly one shape the protocol already forbade in writing and re-blocks no legal workflow: a card claimed once reads as it always did, a re-claim after a `Release:` reads as it always did, a `Clause-②-correction:` is not a claim and never was. The cross-seat question, which is a genuine second capability, is explicitly NOT taken here. ## The pins — per direction, each with a non-vacuity control | | direction | reading | |:---|:---|:---| | (a) | same author, two claims, no retraction | **named, exit 4**; the row carries both ids, the author and both repairs | | (b) | DIFFERENT authors | supersession as today, exit 0 — ⛔ not this state | | (c) | same author after a `Release:` **or** the id-naming retraction | RETRACTED as today, exit 0; the `⛔ NOT superseded` wording byte-unchanged | | (d) | a `Clause-②-correction:` as the later row | silent; the objectstack-ai#17366 exit still reads the declaration off it | | (e) | a DECORATED second claim (bold, backticked) | counted through `markerMatches` exactly as a bare one; the raw constant refuses both, so the counting is the sibling's ONE reading | | (f) | a second claim whose `Branch:` parses to zero branches | still named — the prohibition is on the WRITING, not the parse | | (g) | three claims by one seat | **ONE** refusal naming all three, not two | | (h) | an unattributable row (`rowAuthor` null) | fail closed, as `claimRetractions` does — and `null` never groups with `null` | | (i) | a later same-author comment that QUOTES or DISCUSSES the word | silent — the marker is read at line start | Direction (i) has a control in the wild on this very card: the triage comment 5722477144 contains the word `Claim:` mid-line, and `markerMatches` refuses it — card objectstack-ai#18828 reads one claim comment, so `--pair` on this PR is silent. **Roster line** (:792): `'objectstack-ai#18828: a SECOND \`Claim:\` by ONE seat — the writer-side prohibition, finally READ': 52` — and the battery block declares exactly 52 `t(...)` cases. **Floor** (:806): `SELF_TEST_BATTERY_FLOOR` **31 → 32**, raised by exactly one. ## The ablation Run from the **committed** fix, twice, each leg proving its mutation landed on disk before the reading was taken and proving its restore by an empty `git diff HEAD` and by blob hash — never by an editing command's exit code. `HEAD` blob `3a270ef2eb5f33780e04e4732714f8e88d74a017`. | leg | mutation | mutated blob | result | |:---|:---|:---|:---| | baseline | none | `3a270ef2eb…` | **941 cases pass, exit 0** | | **A** — the repeat detection neutered (a group is never reported) | `72115d2796ead200f93aa855c8ba5820a83f5f8f` | | **23 of 941 failed**, exit 1 | | **B** — the SAME-AUTHOR check removed (the author no longer decides the grouping) | `40cfadd4f5740f34210675ceb998fb2977823769` | | **3 of 941 failed**, exit 1 | Both legs restored: `git diff HEAD` empty, blob back to `3a270ef2eb…`. **Total case count is 941 in all three runs** — the rest of the self-test is byte-identical in its case count, and in both legs **0 of the failures fall outside the objectstack-ai#18828 battery**. Leg A is the interesting one, because it shows the per-direction controls doing their job. Five of the nine directions assert SILENCE and therefore *cannot* go red when the detection is removed — their non-vacuity controls go red instead. All nine directions are carried: - pin itself red: **(a) (e) (f) (g)** - carried by its control: **(b) (c) (d) (h) (i)** — "make those two authors ONE", "drop the retraction", "write that same correction as a SECOND `Claim:`", "give that same row a login", "move that same word to the OPENING of a line" Leg B is the narrower, sharper one: removing only the author test reds **3** cases, and pin (b) is among them. That is the pin which distinguishes this card from the cross-seat question — proof the author test is load-bearing and that (b) is not vacuous. **Self-test count: 889 before → 941 after** (+52, exactly the registered battery). The 889 was measured by running `--self-test` in a detached worktree at `origin/main` `88aa326deb`. ## Gates Derived from the worktree with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no hand-fed path list).⚠️ The first derivation printed **STALE TREE** — the branch was 2 commits behind `origin/main` and 8 files the derivation reads had changed — so `origin/main` was merged in first and the list re-derived on the merged tree at `7424cf3f44`; the `--repo` assertion holds against this checkout's `origin`. **34 derived, 34 run, all exit 0.** Each exit code captured redirect-then-`$?`, never across a pipe. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-changeset-no-major.mjs --self-test :: exit 0 node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-scripts-symbol-anchors.mjs :: exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-clause2-carriers :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` Reconciled with `--ran`, exit codes included: **34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN** — "a DERIVED zero — all 34 recorded an exit code and none of them is 3". Repo-wide `pnpm lint` (`eslint . --no-inline-config`): **exit 0**. The heavy run took a ticket through `scripts/pm/os-verify-lock.sh` (slot `issue-18828-dev`), queued behind the seat's own `dispatch-gates.mjs --self-test`. `node scripts/pm/check-clause2-carriers.mjs --pair` on this PR is reported in the dispatch report — this card carries one claim comment, so the row is silent on it. `skip-changeset`: `scripts/pm/**` publishes nothing from any released package — the whole diff is one non-published script. --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #9446
POST /api/v1/actions/:object/:actionwith atype: 'flow'action now answers the #9378 flow-dispatch status table, from one definition the trigger door reads too.The defect
dispatchFlowAction(packages/runtime/src/action-execution.ts) mapped everysuccess: falseautomation result to one answer —400 FLOW_FAILED— under a comment asserting "The flow RAN and rejected". That is false for two of the four exits it caught: a DISABLED flow invoked through an action told the caller a run had failed when no node ever executed, and the producer's ownresult.codewas available and ignored.The ruling this implements
Maintainer, 2026-08-18, verbatim 「同意」 to the triage recommendation: the #9378 table is a property of the flow-dispatch contract, not of the trigger route, converged in stages. Door 2 converges here; door 3 (declared endpoints) is #9462 and is deliberately untouched.
/actions400FLOW_FAILED404RESOURCE_NOT_FOUND400FLOW_FAILED409FLOW_DISABLED400FLOW_FAILED422FLOW_NO_START_NODE400FLOW_FAILED400FLOW_FAILEDRetry semantics changed, and that is the point
The status is a contract with the console, not just a number. All four answers were
400before, and400 FLOW_FAILEDis terminal to the console — so three refusals that are not terminal-for-the-same-reason were being reported as one. After this change:409 FLOW_DISABLED— reversible operational state. Enable the flow and the identical request succeeds. A caller may legitimately retry after an operator acts.422 FLOW_NO_START_NODE— an authoring defect in the stored definition. Retrying cannot help.404— the action exists, the flow it targets does not. Retrying cannot help; the metadata needs fixing.400 FLOW_FAILED— unchanged, still terminal, still the only row that says a run happened.A caller that treats every non-2xx as "the action failed" is unaffected: all four are still refusals, still
success: false, still with no inner envelope.One definition, not a fourth copy
The mapping lives in a new module,
packages/runtime/src/flow-dispatch-status.ts, and both doors read it. This is the part that matters more than the mapping: three doors each holding their own reading of one engine result is exactly what let them answer differently in the first place, so a second copy of the rule is a defect by construction. #9462 gets the same module to call.respondToFlowTrigger(packages/runtime/src/domains/automation.ts) was refactored to delegate — a pure refactor with no behaviour change, pinned by its own untouched suite (automation-trigger-route-status.test.ts, 26 tests) and by the real-engine integration test in@objectstack/verify. Both stayed green throughout.Declared file-surface expansion, stated rather than smuggled
The dispatch named
action-execution.tsplus its test siblings. Two files outside it were touched, both deliberately:domains/automation.ts— the delegation above. Without it there are still two definitions of one rule, which is the thing the ruling forbids. The file carries no in-flight claim (verified: automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 and automation resume: the request body's OUTER envelope is lenient — an unknown top-level key is silently ignored and the submission is treated as empty #8796 both landed), andpackages/runtimeis serialised to this card this round.content/docs/**—ui/actions.mdxdocumented the old divergence and cited this very issue; leaving it would ship documentation stating the opposite of the behaviour.automation/flows.mdxgains the cross-door note.protocol/kernel/http-protocol.mdx's declared-endpoint row now points at Declared endpoints (type: 'flow'): converge onto the #9378 status table — the ruled follow-up to #9446's staged execution #9462 rather than at this card — once this lands, a reader following that link from a line describing an unconverged door would conclude the door was fixed. That row's own statement (declared endpoints still answer200) is unchanged. To be explicit: Declared endpoints (type: 'flow'): converge onto the #9378 status table — the ruled follow-up to #9446's staged execution #9462 is not addressed here.What deliberately did NOT change
400message keeps its wording (Flow '...' failed: ...). It has been on the wire since [17.0.0-rc] /actions reports business failures as HTTP 200 inside a double envelope — the only route of 12 that does; unify on real status codes before GA #3962, the ruling is about status and code, and it names the flow — which this door needs because the flow name is in the trigger route's URL and nowhere in this one.success: falsestill refuses with400 FLOW_FAILEDhere, deliberately not the trigger door's200. This route settled in [17.0.0-rc] /actions reports business failures as HTTP 200 inside a double envelope — the only route of 12 that does; unify on real status codes before GA #3962 that failures speak HTTP; the alternative residual is the200 {"success":true,"data":{"success":false}}double envelope that ruling removed. The two doors differ here on purpose and each states why at its own site.packages/specedit. All three codes are already registered under@objectstack/runtimeinERROR_CODE_LEDGER; nothing needed widening. Their ledger notes still describe only the trigger door as the emitter — a provenance note that is now incomplete rather than wrong, left to the spec seat rather than edited here.error.details. The trigger door carrieserrorMessage/summarythere; this door throws, and a throw's structured context is whateverresolveThrownHttpErrorreads off it. Out of this card's scope, recorded as a finding.Reverse verification — both directions, predicted before running
A suite that only asserted the new codes would stay green under a regression that collapsed every exit back to one code. Both halves are pinned: each row answers its own status and code, and the rows are asserted as a set so a collapse reddens whatever the surviving answer is.
Then the fix was ablated — the blanket
FLOW_FAILEDrestored at door 2, with the module and the trigger door left alone — and the outcome predicted before the run:getFlowcase, and 3 of the 4 cross-door parity casesexpected 200 to be 404,expected 400 to be 409,expected 400 to be 422200, the unclassified-residual guard, the parity case for the row that already agreedThe four that stay green do not claim to pin the defect: three are regression guards for behaviour that must not change (including the guard against re-introducing the #3962 double envelope, which the ablation restores identically), and the fourth is the parity row that already agreed before this change.
A second reverse check, on the type side: renaming the compared member to a non-member made
tscrefuse it — "types ... and '"FLOW_DISABLED_TYPO"' have no overlap" — which proves the closedAutomationResult.codeunion is being read from a rebuilt declaration rather than a stale oranyone.Verification
All of the below ran at commit
41ff10ed7— the final commit — on a tree whose dependency closure was built first. The gate list was re-derived from the actual changed paths (scripts/pm/dispatch-gates.mjs), not taken from the dispatch's initial list, which named four families; the re-derivation named seventeen plus five convention-triggered ones.Tests
packages/runtimefull suite — 170 files / 2528 tests, all greenpackages/runtimetypecheck (tsc --noEmit) — green--filter '...@objectstack/runtime'):@objectstack/client23 files / 310 tests green -@objectstack/verify7 / 32 green (includes the real-AutomationEnginetrigger-route integration test) -@objectstack/cloud-connection23 / 191 green -@objectstack/http-conformance4 / 72 green -@objectstack/hono2 / 73 greenGates
Green:
check:nul-bytes-check:cross-package-test-inputs-check:route-envelope-check:dispatcher-error-vocabulary-check:docs-audit-scope-check:docs-redirects-check:role-word-check:changeset-gate-self-tests-check:objectui-changeset-check-adr-0087-registration-check-changeset-no-major-check-empty-changeset-check-affected-docs-check:query-options-erasure-check:engine-double-contract-check:where-matcher-check:type-check-coverage- spec liveness:check:liveness,check:empty-state,check:strictness-ledger,check:variant-docs.Not completed locally:
check:type-check-debt --re-measuretimed out under contention for the shared build lock. The thing it would catch was measured directly instead —tscoverpackages/runtimeincluding test files (which its own tsconfig excludes) reports 227 pre-existing errors and 0 in the new test file, so this diff cannot push the frozen count up. Its structural half (check:type-check-coverage) is green above. CI runs the ratchet.Generated by Claude Code