You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec,service-automation): FlowRuntimeState carries WHY a flow is not armed (#18635)
Fixes#18235
Clause-②: yes
`FlowRuntimeState` gains `reason` — the optional sentence saying WHY a
flow is not armed — and the automation engine populates it, so `GET
/automation/_status` can finally tell a policy-disabled flow apart from
a broken binding.
## What was missing
Ruling G item 6 on #17396, verbatim:
> **OFF**: neither trigger arms any flow. Every such flow is listed in
`getTriggerBindingAudit()`, the CLI startup summary and Studio with a
DISTINCT reason — disabled by deployment policy — ⛔ never as "binding
failed".
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 on `origin/main` at `7299b945a2` before writing a line:
```
packages/spec/src/contracts/automation-service.ts:499-517 interface FlowRuntimeState
name / enabled / bound / status? / triggerType? / object?
DARK occurrences of `reason` in that interface block 0
LIT occurrences of `bound` in the same block 2
```
⇒ 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 is `AutomationEngine.getFlowRuntimeStates()`
(`packages/services/service-automation/src/engine.ts`), NOT
`packages/runtime/src/domains/automation.ts`: that door reads the rows
through the contract type and answers `success({ 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`) and `packages/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 shared `SCHEDULED_WORK_DISABLED_REASON`
constant; the CLI startup summary prints that string. `status` and
`triggerType` beside the new key are free `string`s 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 live
`resolveScheduledWorkPolicy()` call. `_status` is 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
| path | what |
|:---|:---|
| `packages/spec/src/contracts/automation-service.ts` |
`FlowRuntimeState.reason?: string` + its docblock — the card's subject |
| `packages/services/service-automation/src/engine.ts` | the producer:
`describeUnboundReason()`, read by `getFlowRuntimeStates()` and
`getTriggerBindingAudit()` |
| `packages/types/src/env.ts` | docblock only — this PR makes
`SCHEDULED_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.mdx` | docs prose only — its callout
said the status door "has no field to say why", which this PR makes
false; corrected on the same ground as `env.ts` (patch round) |
| `packages/spec/src/contracts/automation-service.test.ts` | type-level
identity pins + the runtime probe / lit / dark controls |
| `packages/services/service-automation/src/engine.test.ts` | producer
pins: policy row, anti-drift identity, record-not-environment, bind
failure, DARK absence |
| `packages/runtime/src/domain-handler-registry.test.ts` | the wire pin:
`_status` carries `reason` through, and omits it where the producer
wrote none |
| `.changeset/18235-flow-runtime-state-reason.md` | `@objectstack/spec`
minor, `@objectstack/service-automation` minor, `@objectstack/types`
patch; `Clause-②: yes (widening)` |
⛔ No generated artifact moved — see the DARK control below. Nothing
under `packages/spec/src/ui/`, `packages/spec/src/migrations/**` or
`packages/spec/scripts/` is touched.
## Red before green
`check:generated` is 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 rebuilt `dist`
— before regenerating anything — it was still green (15/15), because no
spec artifact records interface MEMBERS (`api-surface/contracts.json`
records `"FlowRuntimeState (interface)"`,
`export-origins/contracts.json` records its origin, `declaration-map/`
has no `contracts.json` at all, and `api-surface-signatures.json` has
zero `FlowRuntimeState` hits). A gate never observed failing is not
known to be a gate — so the gate that WAS observed failing is
`check:test-typecheck` (CI's required `TypeScript Type Check` job), 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`):
```
Tests 4 failed | 154 passed (158)
× OFF: the status row names the policy, and ⛔ NOT a binding failure
× the two doors answer the SAME sentence for the same flow — one computation, no drift
× the status row reports what HAPPENED, not what the environment says when it is read
× ON: a genuine bind failure reads as one on the status row too
```
⭐ 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-typecheck: 2 problem(s)
• src/contracts/automation-service.test.ts: 4 type error(s) … ARRIVED: TS2339: Property 'reason' does not exist on type 'FlowRuntimeState'.
• src/contracts/automation-service.test.ts: 1 type error(s) … ARRIVED: TS2353: Object literal may only specify known properties, and 'reason' does not exist in type 'FlowRuntimeState'.
LEG2 TYPECHECK EXIT=1
```
⚠️ Reported as observed, not as the template predicts: in LEG 2 the spec
UNIT suite stayed green (17/17). The docblock pin reads the interface's
comment, which the mutation left in place; it is `check:test-typecheck`
that covers the key's existence. Both legs restored from `HEAD` under a
`trap`, and the restore is proved by blob identity (`ec75d9cf` /
`8d9231d4` both back) plus an empty `git 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. ⚠️ Five of them first answered
PREREQUISITE NOT MET (`packages/spec/dist` older than `src`), which is
NOT MEASURED rather than red; re-run after `pnpm --filter
@objectstack/spec build`, all five exit 0.
- Typecheck: `@objectstack/spec`, `@objectstack/service-automation`,
`@objectstack/types`, `@objectstack/runtime` — all 0.
- Tests: spec **484 files / 13821 passed**; service-automation **136 /
1639**; runtime **264 / 3655**; types **22 / 683**.
- `node scripts/pm/check-widening-tells.mjs --declaration yes --diff
PRDIFF` — exit 0.
- Gate reconciliation: `node scripts/pm/dispatch-gates.mjs --ran` — **83
derived, 81 run, 2 NOT MEASURED**. The two are
`check:dual-build-cjs-loads` and `check: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-bytes` green, plus a direct control-character sweep of
all seven touched files: zero hits.
## ⭐ DARK control
- No OTHER interface in `contracts/**` gained a member: the whole diff
under `packages/spec/src/contracts/` is one `reason?: string;` line plus
its docblock, inside the `FlowRuntimeState` block.
- No published payload outside `FlowRuntimeState` moved: every generated
artifact is byte-unchanged (`git status` after the field + rebuild
showed only source files), and `check:generated` agrees at 15/15.
- On the wire, a row the producer left without a reason carries no
`reason` key at all — asserted on both the engine row and the `_status`
response.
## 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.mdx` still carried #18198's sentence that
`GET /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 correct
`packages/types/src/env.ts` in 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 there` hits this file only — the other hits are in
`core/security`, `objectql`, `rest` and `spec`, all unrelated — against
a lit control of 19 files under `content/` that mention `_status`, and
`content/docs/releases/**` (the only other `automation/_status` mention)
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 by `dispatch-gates --ran` at 105
accounted / 103 run / 2 NOT MEASURED / 0 unrun. All 22 new ones green,
including `check:skill-examples` (258 prose examples type-check across 3
surfaces), `check:docs-transcript-drift`, `check:doc-security-posture`,
`check:corpus-claim-drift`, `check:doc-anchors` and
`check:docs-audit-scope`; three of them first answered PREREQUISITE NOT
MET on an unbuilt `@objectstack/lint` / `@objectstack/client-react` and
were re-run after building those closures.
`docs-audit/check-affected-docs.mjs`, `check:doc-authoring`,
`check:keyed-text-bounds`, `check:nul-bytes` and `check: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
- **noted, not filed:** `packages/cli/src/commands/serve.ts`'s startup
banner re-declares the `getFlowRuntimeStates()` row shape inline and
does not name `reason`; 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.
- **to file (class (a), reproducible defect; dedupe words:
`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-inputs` exits 1 on any
tree where `packages/spec/dist` is BUILT —
`packages/cli/test/init-created-files-summary.e2e.test.ts` descends
`packages/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 no `packages/spec/dist` it 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.
⚠️ This branch is 5 commits behind `origin/main` at the time of writing;
none of those commits touches any of the seven paths above (verified
with `git log BASE..origin/main -- THE-SEVEN-PATHS`, empty). The merge
queue rebuilds the PR as merged onto current `main` and 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](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
`FlowRuntimeState` now declares `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can tell a policy-disabled flow apart from a broken binding (#18235).
8
+
9
+
Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow left unarmed because package-authored scheduled work is switched off, and must never read as "binding failed". Two of them shipped: `getTriggerBindingAudit()` and the CLI startup summary. The third — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, and that shape had no field a reason could travel in, so on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'`, byte-identical to one whose trigger is missing.
10
+
11
+
**Clause-②: yes (widening)** — one new key on an already-published payload, so the shape a consumer reads against grows. Nothing previously emitted is removed or renamed, and no producer is required to write it.
12
+
13
+
-**Optional, and additive by measurement.** Every producer of these rows — the engine, and the test doubles in `packages/runtime`, `packages/cli` and `packages/qa/dogfood` — writes `{ name, enabled, bound }` at minimum; a required key would have broken all of them and would demand a reason from rows that have none. The key is absent (not `undefined`-valued) on any row that is bound, disabled, or declares no trigger.
14
+
-**One vocabulary, not a new one.** The sentence is the one `getTriggerBindingAudit()` already answers for the same flow: both doors now read a single private `describeUnboundReason()` on the engine, so Studio and the boot summary cannot drift. A free-form string, matching the two surfaces that already carry this reason; ⛔ consumers render it, they do not parse it.
15
+
-**Read from the RECORD, never re-derived.** The policy sentence comes from the engine's recorded refusal (`policyDisabledFlows`, cleared the moment a flow gets past the gate), never from a live `resolveScheduledWorkPolicy()` read at call time. `_status` is served on demand, arbitrarily long after the bind — re-deriving would report a binding failure for a trigger that was never called, the defect the implementing round of #17396 already caught once.
16
+
-**Wire, not rendering.**`SCHEDULED_WORK_DISABLED_REASON`'s docblock is corrected: Studio's door now carries the reason, while displaying it distinctly remains objectui#9217's card. Declared is not delivered, and reaching the wire is not being shown. The published prose carrying the same claim moves with it — `content/docs/automation/flows.mdx`'s callout said the status door "has no field to say why", which this change makes false; both carriers are corrected in one landing, and neither now claims a console *renders* it.
0 commit comments