feat(spec,service-automation): FlowRuntimeState carries WHY a flow is not armed - #18635
Conversation
… not armed Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow unarmed because package-authored scheduled work is off, and must never read as "binding failed". The third surface — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, a shape with no field a reason could travel in, so a policy-disabled flow reached the wire as `enabled: true, bound: false` — indistinguishable from one whose trigger is missing. - `FlowRuntimeState.reason?: string`, additive: present only on an enabled, unbound row that declares a trigger. - The engine's two doors read ONE private `describeUnboundReason()`, so `getTriggerBindingAudit()` and `getFlowRuntimeStates()` cannot drift. - The policy sentence is read from the RECORDED refusal, never re-derived from a live environment read at call time. - `SCHEDULED_WORK_DISABLED_REASON`'s docblock corrected: the wire now carries the reason; rendering it stays objectui#9217. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
The assertion read the sentence as one line; the docblock wraps it, so the pin failed on formatting rather than on content. Same `[\s*]+` form the neighbouring #16495 pins in this file already use. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 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 20d5ef41618544771b7038888619df88e7ec2f9c && git checkout 20d5ef41618544771b7038888619df88e7ec2f9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cb04f45b24d85db9709a773fc41731e4246febeb 3d558a3ac4eebc773897d1d3f3ac3f7fe6bc4ee2 && git checkout -B drift-repro cb04f45b24d85db9709a773fc41731e4246febeb && git merge --no-ff 3d558a3ac4eebc773897d1d3f3ac3f7fe6bc4ee2
node scripts/docs-audit/affected-docs.mjs --json cb04f45b24d85db9709a773fc41731e4246febeb
|
Contract reviewServed-tier: 119/119 Isolated at-tier clause-② review of PR #18635 for card #18235 (ruling G item 6 on #17396, comment ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
#18198 wrote that `GET /automation/_status` "has no field to say why", so a policy-disabled flow "is indistinguishable there" from one whose trigger is missing. This branch adds exactly that field, so the paragraph is false on its own head — the same ground on which `packages/types/src/env.ts`'s docblock was corrected in this landing. One prose carrier was corrected and its twin was missed. Corrected the same way, and no further: the reason reaches the WIRE, read from the recorded refusal; whether a console renders it as a distinct state is that console's own change, ⛔ not claimed here. Swept for siblings: `no field to say why` / `indistinguishable there` hit this file only (the other hits are core/security, objectql, rest and spec, all unrelated), against 19 files under content/ mentioning `_status` — and `content/docs/releases/**` is release-owned and untouched. One residual, not a class. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 143/143 Second isolated at-tier clause-② review of PR #18635 for card #18235 (ruling G item 6 on #17396, comment ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Clause-② adopted — 第二轮 PASS,双载体同笔剥⏱️ 本条读数取自同一动作:2026-09-17T11:55Z。 复核记录:PR 线程评论 逐字采纳。 ⛔ 未改写、未润色。 独立性对
档位证据143/143 逐条 harness ⭐ 这一轮做了本席特意要求的三件事,而且做到了
③ 的非阻塞条目,记下不动
随后按 Generated by Claude Code |
Fixes #18235
Clause-②: yes
FlowRuntimeStategainsreason— the optional sentence saying WHY a flow is not armed — and the automation engine populates it, soGET /automation/_statuscan finally tell a policy-disabled flow apart from a broken binding.What was missing
Ruling G item 6 on #17396, verbatim:
PR #18198 delivered the first two surfaces and trimmed every published sentence that claimed the third, so nothing published was false. What was missing is that the third surface could not be BUILT: Studio's only status door is
GET /automation/_status→getFlowRuntimeStates()→FlowRuntimeState, and that shape had no field a reason could travel in. Re-measured onorigin/mainat7299b945a2before writing a line:⇒ on the wire a policy-disabled flow was
enabled: true, bound: false, triggerType: 'schedule'— byte-identical to one whose trigger is missing, which is the reading ruled item 6 forbids.The three open questions, answered by measurement
1. Does the runtime producer move in this PR? YES. A declared-but-never-populated key is the ADR-0049 shape this repo files findings about, and the honesty rule is stricter still here:
SCHEDULED_WORK_DISABLED_REASON's own docblock says ⛔ do not write that Studio reports this reason "until a reason reaches that wire shape: declared is not delivered". Shipping the key alone would leave that sentence exactly as false as it is today, and objectui#9217 exactly as blocked. The producer isAutomationEngine.getFlowRuntimeStates()(packages/services/service-automation/src/engine.ts), NOTpackages/runtime/src/domains/automation.ts: that door reads the rows through the contract type and answerssuccess({ flows, total })— a verbatim pass-through with no field picking, so it needed no edit, only a pin.2. Optional or required? OPTIONAL, measured. Producer set: the engine, plus the test doubles in
packages/runtime(domain-handler-registry.test.ts,http-dispatcher.test.ts,automation-run-read-permission-gate.test.ts),packages/cli(serve-automation-summary.test.ts,serve-automation-shadowing.test.ts) andpackages/qa/dogfood— every one of them writes{ name, enabled, bound }at minimum.AutomationEngine implements IAutomationService, so a required key would also have turned its inline return type red. And semantically a required key would demand a reason from rows that have none: a bound flow, a disabled flow, a manual flow.3. Closed union or free string? FREE STRING, matching what already ships.
getTriggerBindingAudit()answers{ flowName, triggerType, reason: string }— a human sentence from a three-branch vocabulary, the policy branch being the sharedSCHEDULED_WORK_DISABLED_REASONconstant; the CLI startup summary prints that string.statusandtriggerTypebeside the new key are freestrings too. objectui#9217's acceptance item 3 says it outright: "The exact reason-code shape is the platform's … if the platform ships a string, render the string." ⛔ No fourth vocabulary was invented.How the vocabulary is held to ONE
Both doors now read one private
describeUnboundReason()on the engine — same eligibility rule (enabled, unbound, declares a trigger), same three branches, same policy sentence read from the RECORDED refusal (policyDisabledFlows) and ⛔ never re-derived from a liveresolveScheduledWorkPolicy()call._statusis served on demand, arbitrarily long after the bind — a strictly worse case for re-derivation than the audit's two boot-time callers, and re-derivation is the exact defect #18198's own round caught and fixed. Pinned as an identity between the two doors rather than as two copies of the expected text, so a future edit to either wording fails instead of forking the vocabulary.File surface
packages/spec/src/contracts/automation-service.tsFlowRuntimeState.reason?: string+ its docblock — the card's subjectpackages/services/service-automation/src/engine.tsdescribeUnboundReason(), read bygetFlowRuntimeStates()andgetTriggerBindingAudit()packages/types/src/env.tsSCHEDULED_WORK_DISABLED_REASON's "Studio is NOT one of them" paragraph false, so it is corrected in the same landing (wire yes, rendering still objectui#9217)content/docs/automation/flows.mdxenv.ts(patch round)packages/spec/src/contracts/automation-service.test.tspackages/services/service-automation/src/engine.test.tspackages/runtime/src/domain-handler-registry.test.ts_statuscarriesreasonthrough, and omits it where the producer wrote none.changeset/18235-flow-runtime-state-reason.md@objectstack/specminor,@objectstack/service-automationminor,@objectstack/typespatch;Clause-②: yes (widening)⛔ No generated artifact moved — see the DARK control below. Nothing under
packages/spec/src/ui/,packages/spec/src/migrations/**orpackages/spec/scripts/is touched.Red before green
check:generatedis NOT a gate for this change, and that is a measurement, not an assumption: run on the clean tree it was green (15/15), and run again with the new field in place and a rebuiltdist— before regenerating anything — it was still green (15/15), because no spec artifact records interface MEMBERS (api-surface/contracts.jsonrecords"FlowRuntimeState (interface)",export-origins/contracts.jsonrecords its origin,declaration-map/has nocontracts.jsonat all, andapi-surface-signatures.jsonhas zeroFlowRuntimeStatehits). A gate never observed failing is not known to be a gate — so the gate that WAS observed failing ischeck:test-typecheck(CI's requiredTypeScript Type Checkjob), plus the producer pins:LEG 1 — the producer stops publishing the key. Mutation written to disk and proved there (anchor occurrences 1 → 0, blob
ec75d9cf→29716a38):⭐ LIT control inside the same run: the 154 that still pass include every pre-existing ruling-G audit pin — the sibling door is untouched — and the DARK absence test passes in the mutated tree too, which is what a well-formed absence assertion must do.
LEG 2 — the contract stops declaring the key (anchor 1 → 0, blob
8d9231d4→0f89941c):check:test-typecheckthat covers the key's existence. Both legs restored fromHEADunder atrap, and the restore is proved by blob identity (ec75d9cf/8d9231d4both back) plus an emptygit diff HEAD, not by an exit code.Evidence
pnpm --filter @objectstack/spec check:generated— 15/15 up to date, clean tree and again at the final tree.check:api-surface,check:docs,check:authorable-surface,check:export-origins,check:declaration-map,check:browser-reachable-entries,check:dual-source-exports,check:entry-nameability,check:exported-any,check:liveness,check:empty-state— all green.packages/spec/distolder thansrc), which is NOT MEASURED rather than red; re-run afterpnpm --filter @objectstack/spec build, all five exit 0.@objectstack/spec,@objectstack/service-automation,@objectstack/types,@objectstack/runtime— all 0.node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF— exit 0.node scripts/pm/dispatch-gates.mjs --ran— 83 derived, 81 run, 2 NOT MEASURED. The two arecheck:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 (PREREQUISITE NOT MET: they need a whole-workspace build closure) — declared to CI, not claimed as passes.pnpm check:nul-bytesgreen, plus a direct control-character sweep of all seven touched files: zero hits.⭐ DARK control
contracts/**gained a member: the whole diff underpackages/spec/src/contracts/is onereason?: string;line plus its docblock, inside theFlowRuntimeStateblock.FlowRuntimeStatemoved: every generated artifact is byte-unchanged (git statusafter the field + rebuild showed only source files), andcheck:generatedagrees at 15/15.reasonkey at all — asserted on both the engine row and the_statusresponse.Patch round — the at-tier review's one FAIL, discharged
The isolated at-tier contract review returned FAIL on one residual and PASS on everything else. The residual:
content/docs/automation/flows.mdxstill carried #18198's sentence thatGET /automation/_status"has no field to say why", so a policy-disabled flow "is indistinguishable there" — false the moment this PR adds the field, and false on exactly the ground used to correctpackages/types/src/env.tsin the same landing. One prose carrier had been corrected and its twin missed. I agree with the FAIL on the merits; no objection recorded.Corrected the same way and no further: the reason reaches the WIRE, read from the recorded refusal, and ⛔ reaching the wire is still not being rendered — whether a console shows it as a distinct state is that console's own change, which this page does not claim.
Own sweep, independent of the one handed to me:
no field to say why/indistinguishable therehits this file only — the other hits are incore/security,objectql,restandspec, all unrelated — against a lit control of 19 files undercontent/that mention_status, andcontent/docs/releases/**(the only otherautomation/_statusmention) is release-owned and untouched. ⇒ one residual, not a class. No pin reads this paragraph: the four tests and scripts that name this page cite other sections, so no test is owed.Re-derived after the docs commit: 105 gate families (22 newly derived by the
content/path), reconciled bydispatch-gates --ranat 105 accounted / 103 run / 2 NOT MEASURED / 0 unrun. All 22 new ones green, includingcheck:skill-examples(258 prose examples type-check across 3 surfaces),check:docs-transcript-drift,check:doc-security-posture,check:corpus-claim-drift,check:doc-anchorsandcheck:docs-audit-scope; three of them first answered PREREQUISITE NOT MET on an unbuilt@objectstack/lint/@objectstack/client-reactand were re-run after building those closures.docs-audit/check-affected-docs.mjs,check:doc-authoring,check:keyed-text-bounds,check:nul-bytesandcheck:generated(15/15) re-run green on the new head. Tests were not re-run: the patch-round diff is one MDX paragraph plus one changeset sentence, and the only gate that compiles docs prose (check:skill-examples) is in the green list above.Acceptance notes
packages/cli/src/commands/serve.ts's startup banner re-declares thegetFlowRuntimeStates()row shape inline and does not namereason; it reads the audit for its unbound section, so nothing is wrong today and the banner needs no change. Carrier: whoever next touches that banner's row type.cross-package-test-inputs·init-created-files-summary·packages/spec/dist walk·#15565 tree-scoped walk·gate vacuous without dist):pnpm check:cross-package-test-inputsexits 1 on any tree wherepackages/spec/distis BUILT —packages/cli/test/init-created-files-summary.e2e.test.tsdescendspackages/spec/dist/and no declared glob reaches inside it. Proven not to belong to this PR: with this branch's entire diff reverted in the working tree the gate still exits 1 with the identical finding, and on a checkout with nopackages/spec/distit exits 0 — i.e. it passes vacuously wherever the lint job has not built spec. Filing is the seat's act; this PR does not touch it.origin/mainat the time of writing; none of those commits touches any of the seven paths above (verified withgit log BASE..origin/main -- THE-SEVEN-PATHS, empty). The merge queue rebuilds the PR as merged onto currentmainand re-runs the required set there.Authored with Claude Code in session
session_01JbZnqu8bt6YqfJsr9vaFb3(both the delivery round and this patch round).Generated by Claude Code