Skip to content

Commit d89f491

Browse files
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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/service-automation': minor
5+
'@objectstack/rest': minor
6+
---
7+
8+
A stored flow the automation engine refuses is no longer served as running: the flow meta read serves the engine's verdict beside the item as `registration`, an envelope sibling of `lock`, and the flow list serves it as `registrations` keyed by flow name, so a refused flow reads `{ state: 'refused', reason: { code?, message } }` beside its stored `status`, which stays as authored.
9+
10+
Clause-②: yes (widening)
11+
12+
- **The shape** (`@objectstack/spec`). `FlowRegistrationSchema` is a closed set: `registered` (the engine holds the flow), `refused` (it refused the stored flow and holds nothing under the name; `reason` carries the engine's own `message` and, when the thrown error set one, its `code`, verbatim), and `unregistered` (it holds nothing and refused nothing: a read during boot, between a save and the engine following it, or after a deliberate unregister). `GetMetaItemResponseSchema` gains `registration` and `GetMetaItemsResponseSchema` gains `registrations`, both optional. New exports: `FlowRegistrationSchema`, `FlowRegistrationRefusalSchema` and the types `FlowRegistration` and `FlowRegistrationRefusal`. `IAutomationService` gains the optional member `getFlowRegistration(name)`.
13+
- **Where the verdict comes from** (`@objectstack/service-automation`). The `kernel:ready` cold-boot bind records each flow it refuses, with what the engine threw, after it withdraws the flow; the `metadata:reloaded` re-sync and the metadata-change sync record a refusal that leaves the name unregistered. A later successful registration of the name, or its withdrawal, ends the record. `AutomationEngine` answers `getFlowRegistration(name)` and takes `recordFlowRefusal(name, error)`. The refusal's warn line, its level and the withdrawal are unchanged.
14+
- **Where it is served** (`@objectstack/metadata-protocol`). The protocol reads the engine through the kernel's service table by name, as it already reads the engine's stored-flow canonicalizer; it does not import the automation service. The field is absent where no automation service declares the member, on every type other than `flow`, on a draft read and on the execution face the engine binds from: absence means no engine judged the flow, never `registered`. The verdict is the engine of the process that answered the read.
15+
- **The default door** (`@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/spec`). `GET /api/v1/meta/flow/:name` on a default deployment (`metadata.enableCache: true`) is answered by the cached item branch, which now carries `registration` too. `getMetaItemCached` returns it beside `data`, read live from its delegate read on every call, and `GetMetaItemCachedResponseSchema` declares it. It is never part of the ETag, so a cached read that carries it is never answered `304 Not Modified`: a judged flow is always served in full. The cached branch's other envelope keys are unchanged.

‎content/docs/automation/flows.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,24 @@ The plugin is a **soft dependency** on `metadata` — it tolerates running
21332133
without `MetadataPlugin` and it logs (not throws) on per-flow registration
21342134
failures so one broken flow does not abort startup.
21352135

2136+
A flow the engine refuses is not served as running. The metadata read keeps
2137+
the stored `status` as you wrote it, and serves the engine's verdict beside
2138+
the item: `registration` on `GET /api/v1/meta/flow/:name`, and `registrations`,
2139+
keyed by flow name, on `GET /api/v1/meta/flow`. The verdict is one of three:
2140+
2141+
| `state` | Meaning |
2142+
|:---|:---|
2143+
| `registered` | The engine holds the flow. Whether its trigger is bound is `GET /api/v1/automation/_status`'s answer. |
2144+
| `refused` | The engine refused the stored flow and holds nothing under its name, so it never fires. `reason` is what the engine threw, verbatim: its `message`, and its `code` when it set one. |
2145+
| `unregistered` | The engine holds nothing under the name and refused nothing: the read came during boot, or between a save and the engine following it, or the flow was unregistered on purpose. |
2146+
2147+
A save, a publish or a reload that registers a corrected body ends the
2148+
`refused` state. The field is absent where no automation service answered the
2149+
read. The item read carries it on the default cached read as well as the
2150+
uncached one: it is read live on every call and kept out of the ETag, so a
2151+
read that carries it is always answered in full, never with `304 Not
2152+
Modified`. It is the verdict of the engine in the process that answered.
2153+
21362154
## Expressions in flows
21372155

21382156
A flow computes with **one expression dialect, CEL**: every condition is bare

0 commit comments

Comments
 (0)