Repository navigation
Commit d89f491
fix(automation,metadata-protocol,rest,spec): a stored flow the engine refuses is served beside its verdict, not as running (#23358)
Fixes #23316
Clause-②: yes (the flow meta read and list gain a read-time field: the
engine's verdict on a stored flow)
## What changes
A stored flow the automation engine refuses is no longer served as
running. The stored row's `status` stays exactly as authored; the
engine's verdict is served beside the item:
- **Item read**, both branches of `GET /api/v1/meta/flow/NAME`:
- uncached (`getMetaItem`): a new envelope key `registration`, a sibling
of `lock`;
- the DEFAULT cached branch (`metadata.enableCache: true`): the same
`registration`, read live on every call (claim amendment `6157354342`).
- **List** (`getMetaItems` / `GET /api/v1/meta/flow`): a new top-level
key `registrations`, keyed by flow name, beside `items`. The REST list
chain keeps sibling keys (`withItems` spreads the protocol's envelope),
so both transports serve it.
The closed state set is the one the engine can actually answer, total
over its state for a name:
| `state` | When |
|:---|:---|
| `registered` | the engine holds a registration under the name |
| `refused` | the engine's last attempt to register the stored flow
threw and it holds nothing under the name; `reason` = the thrown error's
own `message`, and its string `code` only when the error set one
(verbatim, never paraphrased, never invented) |
| `unregistered` | the engine holds nothing and recorded no refusal (a
read during boot, between a save and the engine following it, or after a
deliberate unregister) |
`refused` requires `reason` (a discriminated union), so a refused
verdict without its reason does not parse.
**An unjudged flow reads the field as ABSENT, not as a third state.**
The field is the engine's answer or nothing. Where no automation service
declares the member (a control-plane or metadata-only host), the
protocol has no engine to ask, and a protocol-minted `unknown` would be
a verdict nobody made. Both schema members document absence as "no
engine judged it, never `registered`". The same absence applies to every
type other than `flow`, to draft reads (a pending body is not what the
engine holds), and to the execution face the engine binds from.
## The seam (the claim's premise, verified before any edit)
The protocol reads the verdict through the kernel's service table by
name — `this.getServicesRegistry?.().get('automation')` — resolved
lazily per call. Precedent: `resolveFlowCanonicalizer`
(`packages/metadata-protocol/src/protocol.ts`), which reads
`canonicalizeStoredFlow` from the same service the same way, for the
save door and the stored migration. The member is declared on the
existing contract `IAutomationService` in `@objectstack/spec/contracts`
(`getFlowRegistration?(name)`, optional, beside `canonicalizeStoredFlow`
and `getFlowRuntimeStates`), which `AutomationEngine` implements; the
automation plugin registers the engine itself under `automation`.
`metadata-protocol` imports nothing from `service-automation`.
The REST cached branch reaches the same seam through the protocol.
`getMetaItemCached` already performs the uncached read on every call (it
is a validator wrapper, not a store), so it returns that read's live
`registration` beside `data`. `GetMetaItemCachedResponseSchema` declares
it, and `rest-server.ts` spreads it into the cached envelope for `flow`
only.
## The default cached branch: live, not stored, not in the ETag
- `getMetaItemCached` hashes the document alone, as before. The verdict
is not stored anywhere and is never part of the ETag.
- **Consequence, and the reason for it:** a cached read that carries
`registration` is never answered `304 Not Modified`. The protocol's own
invariant at that validator says a 304 may be answered only on an exact
match over the bytes actually sent. With the verdict outside the hash, a
304 would pin the caller to the verdict it was served before. That
happens when the engine re-registers or refuses the flow under an
unchanged body, for example on a restart with a different plugin set, or
when the post-save sync lands after the first read. Answering in full
costs only the saved body bytes, since the method performs the delegate
read either way (the same trade `rest-server.ts` records for
`dashboard`). An unjudged flow (no automation service) keeps its 304
exactly as before.
- Every other type's cached envelope is byte-identical (pinned).
## Where the verdict is recorded (`service-automation`)
- **The `kernel:ready` cold-boot bind** records each refused name with
what the engine threw, after the withdrawal. The withdrawal itself, the
warn line and its level are unchanged.
- **The `metadata:reloaded` re-sync** and **the metadata-change sync**
(a save, publish, delete, revert at runtime) record a refusal that
leaves the name unregistered. A refused re-registration of a name the
engine still holds leaves the old registration in place (`registerFlow`
throws before it changes anything), so that name stays `registered`.
- **Not recorded: the boot pull** in `start()`. The bind re-judges every
flow of the view at `kernel:ready`, so a record made there is a verdict
this same boot can contradict (AGENTS.md "Startup registry reads").
`check:startup-registry-verdict` is green.
- **Cleared** by a later successful `registerFlow` of the name and by
`withdrawFlow`. `recordFlowRefusal` ignores a name the engine holds, so
a name is never `refused` and registered at once.
## Multi-process
The verdict is the engine of the process that answered the read; no
cross-process store is consulted. The schema members and the docs page
(`content/docs/automation/flows.mdx`, "Discovery & Registration") say
so.
## Tests
-
`packages/services/service-automation/src/flow-refused-registration-read.integration.test.ts`
(new): the triage pin over the real composition (ObjectKernel,
ObjectQLPlugin's metadata protocol on driver-sql / better-sqlite3 in
memory, the real automation plugin).
- Setup: two stored `sys_metadata` flow rows land after ObjectQL
started, so the boot pull never sees them. The node type's executor
registers from a later plugin's `start()` (the #21848 harness shape), so
the `kernel:ready` bind refuses the row whose plugin node carries an
undeclared key.
- The item read serves `item.status === 'active'` beside `registration:
{ state: 'refused', reason: { message } }`. The message equals what
`registerFlow` throws for that body, with no `code` (a plain error).
- The cached read, the producer behind the default door, carries the
same refusal and `registered` for the control.
- Control: the registered row reads `{ state: 'registered' }`, its
status as stored.
- The list carries both verdicts in `registrations`, keyed by name, with
the items bare.
- A corrected save clears the refusal (`registered`).
- `packages/rest/src/meta-flow-cached-registration.test.ts` (new): the
default door.
- Asserted on the cached branch (`getMetaItemCached` called,
`getMetaItem` not).
- A refused flow: the stored status unchanged, `registration: refused`
with its reason beside it.
- Control: a registered flow's cached body is exactly `{ type, name,
item, registration: registered }`.
- Control: another type's cached envelope is exactly what it was.
-
`packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts`:
nine cases on that file's already-pinned engine double (its `harness`
gains an optional service table).
- The engine's answer passed through beside `lock`, never inside `item`.
- Absent with no automation service, and with one that declares no
member.
- Absent for `view`, and the engine is never asked.
- Absent on a draft read.
- The list is keyed by name, a loader's flow included. The execution
face carries none. A pending draft gets no entry.
- The cached read carries the verdict with an ETag equal to the unjudged
read's.
- A judged cached read is never answered 304; the unjudged control still
is.
- `packages/spec/src/api/meta-item-response-shapes.test.ts`:
- compile-time pins: `registration` is the verdict or undefined;
`registrations` is a name-keyed record of verdicts or undefined;
- the three states parse; a fourth state and a reason-less `refused` are
refused; absence stays absent.
- `packages/spec/src/type-alias-convention.pin.test.ts`: the two new
schemas are pinned isomorphic (ADR-0122); after the merge with PR
#23348's seven pins, the count is 783 to 785.
`packages/spec/variant-docs.json` binds the three states to the flows
page (`check:variant-docs`).
**Ablations** (`scripts/ablation-replace.mjs`, wrap mode, subject
imported from source in both, so no `dist/` sits on the path):
1. **Engine record.** The bind's `recordFlowRefusal` call was replaced
by a marker comment: anchor 1 to 0, marker 0 to 1, blob `29d687232ea8`
to `cba906b7c690`.
- The pin went red, 2 failed / 2 passed: the refused item read answered
`{ state: 'unregistered' }`, and so did the list entry.
- Restored: blob == HEAD `29d687232ea8`, and `git diff HEAD` is empty.
- A first attempt was a no-op (the tool refused a replacement that was a
substring of the anchor; nothing ran) and is not counted.
2. **REST spread.** The cached envelope's `...cachedRegistration` was
replaced by a marker comment: blob `41d526941b28` to `0e5e6328b07b`.
- The REST pin went red, 2 failed / 1 passed: the refused read had no
`registration`, and the registered control lost it. The other-type
control stayed green.
- Restored: blob == HEAD `41d526941b28`, and `git diff HEAD` is empty.
## Verification (local, shared box; CI runs the full battery)
Final head `93a72727a`: this branch merged with `origin/main` at
`6d96721fa`, then at `18cd410dc` (`ba00d5942`, via
`scripts/pm/os-regen-merge.sh`) after PR #23348 landed beside it. The
two hand-kept registration files (`type-alias-convention.pin.test.ts`,
`variant-docs.json`) were resolved as the union of both sides, and
`content/docs/references/index.mdx` was regenerated (`93a72727a`). The
contract review PASSed on `2b84fe555` (`6159075428`), and the delta
review of the merge PASSed on `93a72727a` (`6159900532`).
- **Typecheck, final head:** `@objectstack/spec` (tsc, scripts, test
layer), `@objectstack/metadata-protocol`,
`@objectstack/service-automation` (and its test layer),
`@objectstack/rest` (and its test layer). All exit 0.
- **Derived gates, final head:** `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --ran` (exit-annotated record)
printed: "Run reconciliation — 116 derived, 114 run, 2 NOT-MEASURED, 0
UNRUN". All 114 exit 0. The two NOT MEASURED families:
- `check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET. It reads
every workspace package's `dist/`, and a full `pnpm build` was not run
on the shared box.
- `check:type-check-debt`: its `--re-measure` rebuilds and type-checks
the whole workspace, past the foreground cap. A first attempt was
cap-killed and left half-built `dist/`s in this worktree, which were
rebuilt before `check:dts-closure` was re-run green.
- **Full suites, all green** (sharded under `os-verify-lock`):
- on `8f39c942f` (after the first main merge, before the cached-branch
widening): `@objectstack/service-automation` 186 files / 2453 tests;
`@objectstack/metadata-protocol` 238 files / 28339 tests (+19 skipped);
`@objectstack/spec` (local project) 669 files / 20278 tests;
- on `9c498cda5` (after the widening): `@objectstack/rest` (local
project) 280 files / 5839 tests (+326 skipped), plus the five
`getMetaItemCached` files of `metadata-protocol`, 132 tests.
- **Re-run on the final head** (declared narrowing; CI runs the full
suites on the merged head):
- the pin files above;
- every test file `origin/main` changed in these four packages: 55 + 94
+ 27 + 20 tests;
- `pnpm --filter @objectstack/spec test:repo`: 58 of 59 files green (35
tree-scoped pins / 474 tests; 21 script suites / 425; 2 publish-smoke /
24).
- NOT MEASURED: `scripts/build-schemas-check-mode.test.ts`. Run alone,
it passed 35 tests and was then cut at the 7.5-minute foreground cap
under lock contention. Left to CI.
## Acceptance notes
Out-of-scope findings, not filed (the seat decides):
- **The default cached branch still serves no protection envelope**
(`lock`, `version`, `editable`, `deletable`, `resettable`, provenance).
This is pre-existing and already declared in
`GetMetaItemResponseSchema`'s docblock, which leaves it open (the sweep
of #5950). Per the amendment it is not this card's. Carrier: the
`domain:spec` seat.
- **objectui (H5), measured read-only at `7578323`.** No console surface
reads the new `registration`. Today a refused flow shows as:
-
`packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:6412`:
the open flow's runtime row comes from `GET /api/v1/automation/_status`.
A refused flow has no engine row (`null`), so the header pill and the
Problems panel read `unpublished` ("Unpublished draft", amber). That is
the wrong reason, though not "running".
- The same file at `:5700` (`FlowStatusDot`): no row means no dot on the
rail.
-
`packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts:375`
(`deriveFlowRunStatus`): with no runtime reading (no provider, or
`_status` not read yet) it falls back to the stored `status`, so a
refused `active` flow renders `enabled`.
- Carrier: the seat files the objectui follow-up.
- **Read from source only, not reproduced through a public door.** The
metadata-change sync and the reload re-sync warn "the engine keeps the
registration it had" when a changed flow is refused. If the save door
accepts a body `registerFlow` then refuses, the engine runs the older
body while the read serves the newer one beside `registration:
registered`. Whether a door accepts such a body was not measured.
Carrier: none.
- **Tooling observation.** `pnpm check:type-check-debt` runs unlocked as
a `check:*` gate, but its `--re-measure` builds workspace `dist/`s. When
cap-killed mid-run, it leaves packages with JS deleted and declarations
stale (`tsup` clean). Carrier: none.
Dedupe words: flow registration verdict meta read; refused flow
registration served beside status; cached meta item branch missing
envelope lock version.
---
_Generated by [Claude
Code](https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 28e294f commit d89f491
22 files changed
Lines changed: 1033 additions & 21 deletions
File tree
- .changeset
- content/docs
- automation
- references
- api
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- metadata-protocol/src
- rest/src
- services/service-automation/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
- contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2133 | 2133 | | |
2134 | 2134 | | |
2135 | 2135 | | |
| 2136 | + | |
| 2137 | + | |
| 2138 | + | |
| 2139 | + | |
| 2140 | + | |
| 2141 | + | |
| 2142 | + | |
| 2143 | + | |
| 2144 | + | |
| 2145 | + | |
| 2146 | + | |
| 2147 | + | |
| 2148 | + | |
| 2149 | + | |
| 2150 | + | |
| 2151 | + | |
| 2152 | + | |
| 2153 | + | |
2136 | 2154 | | |
2137 | 2155 | | |
2138 | 2156 | | |
| |||
0 commit comments