Skip to content

feat(spec): FLOW_DISABLED / FLOW_NO_START_NODE complete the trigger status ruling (409 / 422) - #9452

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9415-flow-refusal-codes
Aug 18, 2026
Merged

feat(spec): FLOW_DISABLED / FLOW_NO_START_NODE complete the trigger status ruling (409 / 422)#9452
os-zhuang merged 1 commit into
mainfrom
claude/issue-9415-flow-refusal-codes

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#9378

Implements #9415 — the spec-seat fragment the #9384 ruling routed to this lane. Merging completes all four rows of the #9378 trigger-status ruling; PR #9413 delivered rows 1-2 (404, and 400 FLOW_FAILED) and stopped on the other two exactly as dispatched.

⚠️This PR does not carry a closing keyword for #9415 — the dispatch specified Fixes #9378 and was silent on the delivery card, and inventing a second auto-close is not mine to do. #9415 is fully delivered here and can be closed on merge.

Why the union was widened rather than worked around

The two never-dispatched exits could not be told apart because the closed AutomationResult.code union had no honest member for either: RUN_NOT_FOUND is documented as a resume fact, and nothing mapped to 422 at all. #9413 measured both workarounds and rejected them, and this PR does not quietly reintroduce either:

  • matching the engine's message text is a regex on prose (PD Add comprehensive test suite for Zod schema validation #12);
  • a route-side enable-state probe duplicates the engine's own execution policy in the transport and opens a TOCTOU window that answers "disabled mid-flight" as "malformed definition".

So this is the deliberate widening with measured need that the #9384 ruling routes to the spec seat — not a mid-PR mint at a call site. The union stays closed; it gained exactly two members, each with a documented condition and a registered wire code.

The contract, complete

Engine exitRealityAnswer
flow not foundnever dispatched404
flow disablednever dispatched409FLOW_DISABLED
flow has no start nodenever dispatched422FLOW_NO_START_NODE
ran and failed (retry-strategy exits included)ran, rejected400FLOW_FAILED
succeeded, or PAUSED at a screen node200

Why two statuses and not one shared 4xx: a disabled flow is reversible operational state — enable it and the identical request succeeds, which is 409's meaning. A flow with no start node cannot be executed as stored and no retry helps, which is 422's. Collapsing them would tell an operator to flip a switch that will not help.

What changed

  • packages/spec/src/contracts/automation-service.tsAutomationResult.code gains 'FLOW_DISABLED' and 'FLOW_NO_START_NODE', documented as a trigger-time refusal group classified before dispatch, kept visibly separate from the resume-refusal members (whose "refuses before consuming the suspension" closing sentence is now correctly scoped to them alone).
  • packages/spec/src/api/error-code-ledger.zod.ts — both registered under @objectstack/runtime, following FLOW_FAILED: the door that names the wire vocabulary, not the producer. Neither is a standard-catalog synonym, so neither needs a waiver.
  • packages/services/service-automation/src/engine.ts — the disabled-flow and no-start-node exits stamp their codes and continue to carry no status. That absence is fix(automation): answer real HTTP status codes on both trigger routes #9413's provable separation of never-dispatched from ran-and-failed, and the test asserting it was strengthened, never relaxed.
  • packages/runtime/src/domains/automation.ts — two arms in respondToFlowTrigger read the producer's codes. No inspection of summary, durationMs or the message text.
  • Tests — the two "stay unchanged at 200" pins, whose own comments said they flip when this lands, now pin 409/422 with the history note. Client-side pins mirror the FLOW_FAILED ones.
  • Docs — the five pages the fix(automation): answer real HTTP status codes on both trigger routes #9413 drift advisory named, re-verified against the now-complete contract.

Bounded in-place fix, named with its evidence

The same TSDoc block carried two stale bullets naming 'PERMISSION_DENIED' and 'INVALID_SIGNAL' by lowercase spellings ('forbidden', 'invalid_signal'). Evidence that the union declaration is the correct form, not the prose: the union has never contained either spelling, and the engine stamps the SCREAMING_SNAKE members (engine.tscode: 'PERMISSION_DENIED' and code: 'INVALID_SIGNAL'); a repo-wide grep finds no producer of the lowercase forms. Corrected in place because the fix is mechanical, the correct form is pinned by the declaration three lines below it, no other in-flight branch touches this file, and it adds no verification surface — leaving it would have put new members beside two wrong ones.

Docs re-verification (the #9413 drift advisory's five pages)

The advisory was right that these pages had drifted, and further than expected — none of the five documented the trigger route's failure semantics at all, before or after #9413.

PageFindingAction
automation/flows.mdx"Run a flow via API" showed only the success shape; the success: true callout implied every outcome rides a 200Added the four-row status table with a worked 409 body and an upgrade callout; rewrote the callout so a skip stays distinguishable from a refusal
api/client-sdk.mdxThe SDK tour called trigger() with no mention that a failed run REJECTSAdded the catch shape with the codes a caller branches on
api/plugin-endpoints.mdxThe trigger row described the route with no status informationAdded the four statuses plus a link to the full table
protocol/kernel/http-protocol.mdxClaimed a type: 'flow' endpoint is "delegated to the same automation pipeline" — true of the context builder and the execute call, no longer true of the responseCorrected to state the boundary; see the finding below
ui/actions.mdxClaimed the flow action type is "equivalent to" the trigger routeCorrected: /actions answers 400 FLOW_FAILED for every unsuccessful outcome

Generated content/docs/references/** was regenerated by gen:docs, not hand-edited. Nothing under content/docs/releases/ was touched.

Out-of-scope finding filed, not fixed here: #9446

Two of the three doors that dispatch a flow never received the #9378 table. /actions with type: 'flow' maps anysuccess: false to 400 FLOW_FAILED (its own comment says "The flow RAN and rejected", now false for two of the exits it catches), and metadata-declared type: 'flow' endpoints still answer 200 unconditionally with the double envelope. Not fixed here: #9378 ruled the two trigger routes, and what the other doors should answer is a contract decision of its own — door 3 additionally carries the endpoint policy chain and outputMapping, so converging it is not a copy of the mapper. This PR only corrects the two doc pages that claimed equivalence.

Model tier — mandate, measured exemption, compensating control

This card is fable-mandatory by clause ② of the model-tiering ruling, judged from card content: it widens a closed public union in packages/spec governing contract accept/reject vocabulary.

The first dispatch at that tier terminated on a measured quota exhaustion — the runtime reported the tier's usage limit reached, verbatim in the claim comment on #9415. That is the ruling's one sanctioned exit from a mandatory tier: the tier unavailable ⇒ opus, never lower, recorded with its reason. This PR is that opus re-dispatch. The tier was not waived on judgment; it was made unavailable by a metered limit, measured rather than assumed.

The compensating control is review, not hope. The PM reviews this diff against the recorded #9378 ruling row by row, and checks the two spec members against the ADR-0112 ledger and the api-surface snapshot by measurement rather than by this report. If the contract reasoning reads thin, the card returns to the queue for capacity at the mandated tier rather than landing on a lower-tier judgment call.

The path derivation reports no mandate for these paths — verbatim tool output, node scripts/pm/dispatch-gates.mjs --tier over the actual changed paths:

Model tier — no path-derived mandate: the surface hits none of the 1 declared glob(s), derived here, not recalled.
The tier stays the PM's per-card judgment call (floor sonnet · default opus · ceiling fable).
Clause ② is NOT reachable from paths: a card that changes contract accept/reject behaviour or widens the public surface is fable-mandatory too, judged from the card CONTENT. This line is a FLOOR, never a clearance.
Clause ② SUSPECT surface — a hint, not a verdict: judge the tier from the card CONTENT as best you can (a card changing contract accept/reject behaviour or widens the public surface is claude-fable-5); whichever tier is dispatched, the PR's actual diff passes the clause-② enqueue gate before the card may enqueue.
- packages/spec/src/api/error-code-ledger.zod.ts ⇢ 'packages/spec/src/**' — the contract surface (error-code ledger, *.zod.ts contract schemas) — the normal landing zone of a clause-② card
- packages/spec/src/contracts/automation-service.ts ⇢ 'packages/spec/src/**' — the contract surface (error-code ledger, *.zod.ts contract schemas) — the normal landing zone of a clause-② card

So the mandate here is clause ②, reachable only from content — and the derivation independently flags both spec files as the clause-② suspect surface.

Verification

Everything below was run at f4b13fb98, the final commit, on a clean tree (git status --porcelain empty, restores proven byte-identical by comparing git hash-object against the blob id each path has in HEAD).

Suites — all green:

packages/runtime Test Files 167 passed (167) Tests 2503 passed (2503)
packages/services/service-automation Test Files 80 passed (80) Tests 974 passed (974)
packages/client Test Files 23 passed (23) Tests 310 passed (310)
packages/spec Test Files 409 passed (409) Tests 10915 passed (10915)

typecheck green for spec, runtime and client. service-automation declares notypecheck script — the filter matched it and ran nothing, so it is reported as not covered there rather than as a fourth pass; it is a DEBT ledger entry instead, re-measured below.

Reverse verification — direction predicted before each run, and all three came out plain red as predicted:

AblationPredictedObserved
remove the FLOW_DISABLED armfalls past the status === 'failed' check (a refusal has no status) to deps.success ⇒ 2006 failed / 20 passed — expected 200 to be 409, both routes; the 422 pins stayed green
remove the FLOW_NO_START_NODE armsame fall-through ⇒ 2004 failed / 22 passed — expected 200 to be 422, both routes
stamp status: 'failed' on a never-dispatched engine exitthe engine's no-status invariant reddens2 failed / 142 passed — expected 'failed' to be undefined, twice

The third is the invariant #9413 built, re-asserted rather than weakened. Worth stating precisely: the route tests would stay green under that mutation, because they drive a hand-built fake service and because the code arm is consulted before status. The engine test is the sole guard on it, which is why it was strengthened in the same change.

Generated artifacts.check:generated reports all 13 up to date at the final commit. The measurement the card asked for rather than trusting: check:api-surface is green with the union widened — the snapshot records "AutomationResult (interface)" with no member detail, so #9413's note holds for that artifact. It does not hold for the whole set: check:docs went stale, because the ledger addition lands in content/docs/references/**. Regenerated with gen:docs.

Gates. The named families plus the union re-derived from the actual changed paths (node scripts/pm/dispatch-gates.mjs), all green:

check:dispatcher-error-vocabulary (reports 292 registered codes, 12 unregistered sites all classified, 0 awaiting a ledger entry — both new codes are registered, so no declaration-table row is required) - check:error-code-casing - check:nul-bytes - check:route-envelope - check:cross-package-test-inputs - check:changeset-gate-self-tests - check:objectui-changeset - check:docs-audit-scope - check:docs-redirects - check:role-word - check:quick-reference-counts - check:doc-formula-expressions - check:spec-parsed-alias - check:type-source-resolution - check:test-source-alias - check:merge-driver - check:engine-double-contract - check:where-matcher - check:query-options-erasure - check:adr-0087-registration - check:empty-changeset - check:changeset-no-major - docs-audit/check-affected-docs - spec liveness family (check:empty-state, check:variant-docs, check:liveness, check:strictness-ledger) - check:type-check-coverage.

The re-derivation added the ratchet family, which the named list did not carry. check:type-check-debt --re-measure refused on an unbuilt closure — correctly, since a number measured there is a different world — so the full workspace was built first (turbo run build, 70/70 successful) and then re-measured:

check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 224.5s, 1926 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.

That is load-bearing here rather than a formality: @objectstack/service-automation is a DEBT entry whose note names engine.test.ts — the file this PR adds tests to — and with zero surplus any new tsc error would have gone red.

Changesetminor across spec / service-automation / runtime / client, per the fixed-group convention (major is refused by check:changeset-no-major during the launch window). Its body extends #9413's migration story: that changeset's "Also unchanged, pending a ruling" paragraph is quoted and marked resolved.

Not done, deliberately

Draft, and not marked ready; auto-merge not armed. PM reviews first, per the tier note above.


Generated by Claude Code

…tatus ruling (#9415)
The #9378 ruling classified four trigger-route outcomes. PR #9413 delivered two
of them (404, and 400 FLOW_FAILED) and stopped on the other two exactly as
dispatched: a DISABLED flow and one with NO START NODE are both exits that
never dispatched anything, and the closed `AutomationResult.code` union had no
honest member for either. The two workarounds were measured and rejected — a
regex on the engine's message prose, and a route-side enable-state probe that
duplicates execution policy and opens a TOCTOU window answering "disabled
mid-flight" as "malformed definition".
This is the spec-seat fragment the #9384 ruling routes here: a deliberate union
widening with measured need, not a call-site mint.
- spec: `AutomationResult.code` gains 'FLOW_DISABLED' / 'FLOW_NO_START_NODE',
documented as trigger-time refusals classified BEFORE dispatch and grouped
apart from the resume-refusal members. Both registered in the ADR-0112
error-code ledger under @objectstack/runtime — the door that names the wire
vocabulary, following FLOW_FAILED.
- engine: the disabled-flow and no-start-node exits stamp their codes and
continue to carry NO `status`. That absence is #9413's provable separation of
never-dispatched from ran-and-failed, and the test asserting it is
strengthened rather than relaxed.
- route: two arms in `respondToFlowTrigger` read the producer codes —
FLOW_DISABLED => 409, FLOW_NO_START_NODE => 422. No sniffing of `summary`,
`durationMs` or the message text.
- tests: the two "stay unchanged at 200" pins, written to flip when this landed,
now pin 409/422; both directions are covered (a classified exit is answered by
its code even carrying a failed run's incidental fields; an UNCLASSIFIED exit
still stays 200). Client-side pins mirror the FLOW_FAILED ones.
- docs: the five pages the #9413 drift advisory named, re-verified against the
now-complete four-row contract.
Also corrects two stale TSDoc bullets on the same union that named
'PERMISSION_DENIED' / 'INVALID_SIGNAL' by lowercase spellings the union has
never contained and no producer emits (engine stamps the SCREAMING_SNAKE
members; ADR-0112 D6).
Fixes#9378
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/client, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 6 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol))
  • content/docs/automation/flows.mdx(via AutomationResult (symbol))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 119 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json origin/mainpackageMentionDocs.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

ACCEPT — PM review at f4b13fb98 (dispatching seat, session session_01NYgmGheCzM6NrHZN436Cxf).

This card ran at a downgraded tier (fable quota exhausted mid-dispatch; the measured exemption is recorded on #9415), and I said publicly that the compensating control would be a contract review by measurement rather than by report. That review is done: route mapper, engine (both guard copies), both test files, the client surface, the generated reference pages, and the changeset were read directly. The spec union itself is corroborated downstream — contract.mdx's enumeration count moves 283 → 285 and error-code-ledger.mdx gains exactly the two members, both of which are generated from the union, and check:dispatcher-error-vocabulary reports 292 registered codes with 0 awaiting a ledger entry.

The contract reasoning holds, and it holds in both directions

The heart of this diff is a division of labour between two fields, and it is stated where the code is rather than only in the PR: code says why a dispatch was refused; status says how a run that started ended. Never-dispatched exits gain a code and still carry no status; the ran-and-failed exit keeps status and gains no code. The engine test asserts both directions in one case, so the two classifications cannot quietly merge into one field — which is the failure this widening would otherwise invite.

The invariant that #9413 built is not just preserved but hardened: the no-status assertion now carries a ⚠️ note saying it is the invariant and may only ever be strengthened, spelling out how a later "stamp status: 'failed' everywhere for consistency" tidy-up would silently put every refusal back on the 400 arm and collapse the four-row table. The route's arms read code and nothing else, and the surviving half of the old pin — an unclassified never-dispatched exit still answers 200 — keeps the transport from ever promoting an exit it was not told about.

The strongest signal in the delivery is a test that was NOT written

executeWithoutRetry carries its own copies of both guards. They are classified identically in source, and deliberately left unpinned, with the reason in the file: a test driving a disabled flow with errorHandling.strategy: 'retry' returns from the outer guard, so it would assert the right value for the wrong reason and stay green with the inner copies unclassified — the phantom-check shape this repo pins against elsewhere. Reachability, not spelling, is what a future change would have to establish first. Writing the vacuous test and claiming the coverage was the easy path; declining it and saying so in the source is the standard.

Also correct, and measured rather than inherited: #9413's note that the api-surface snapshot records AutomationResult without member detail was re-verified rather than trusted — true for check:api-surface, false for the set as a whole, because the ledger addition lands in content/docs/references/** and check:docs went stale until gen:docs ran.

Your two open questions, answered

1 — who closes #9415: A, and the PM does it. The dispatch specified Fixes #9378 and was silent on the delivery card; minting a second auto-close was not yours to add. #9415 is fully delivered, and I close it when this merges, citing this PR.

2 — the tier output containing a model constant: A, keep it verbatim. Altering quoted tool output is the same class of edit this repo forbids on quoted rulings, and the no-identifiers rule is about attribution in your own prose — which names tiers only. There is repo precedent in the same direction: #9238's body quotes the derivation verbatim, mandate line included. Flagging it rather than silently choosing was right.

#9446 — the other two flow-dispatch doors (/actions with type:'flow', and metadata-declared type:'flow' endpoints) — is correctly filed rather than folded in: door 3 carries its own policy chain and outputMapping, so converging it is a contract decision, not a copy of this mapper. This PR only corrects the two doc pages that claimed the doors were equivalent, which is exactly the right scope.

Landing: flipped ready + armed. Merge closes #9378 (all four ruled rows delivered) and I close #9415 with it.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 18, 2026 01:43
@os-zhuang
os-zhuang enabled auto-merge August 18, 2026 01:43
@os-zhuang
os-zhuang added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 9aa8890Aug 18, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9415-flow-refusal-codes branch August 18, 2026 02:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: both trigger routes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume

2 participants

@os-zhuang@claude