Repository navigation
fix(automation,metadata-protocol,rest,spec): a stored flow the engine refuses is served beside its verdict, not as running - #23358
Conversation
…red flow beside the meta read Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…ads refused, with its reason Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…othing, beside the item and the list Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…fused-flow-runtime-state
…gine double Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…c (ADR-0122) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…es the engine's verdict, read live Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…he default cached flow read Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
… changeset names rest Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
…fused-flow-runtime-state
📓 Docs Drift CheckThis PR changes 4 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 145 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 34b0e4901de4bec3e596fd7e69a63ae5c626da6c && git checkout 34b0e4901de4bec3e596fd7e69a63ae5c626da6c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 18cd410dc4053cf8983993104e325f0bbef19258 93a72727a561114fadc0693c491962d04431f4fc && git checkout -B drift-repro 18cd410dc4053cf8983993104e325f0bbef19258 && git merge --no-ff 93a72727a561114fadc0693c491962d04431f4fc
node scripts/docs-audit/affected-docs.mjs --json 18cd410dc4053cf8983993104e325f0bbef19258
|
Contract reviewServed-tier: Inputs read: card #23316 (body; triage ① Derived judgments(a) The ruling, the premise, the ban — held.
(b) Recording correctness — correct, with one dormant path noted in ③.
(c) The cached REST branch (the amendment) — verdict live, never stored, never in the ETag; A as shipped.
(d) The list — as ruled. (e) Pins — the real composition, with controls; ablations directionally consistent, one count not. (f) Semver and docs — true and minimal. See ②. (g) Overlap with #23317 ( (h) CI on the head, read once at ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…fused-flow-runtime-state # Conflicts: # packages/spec/src/type-alias-convention.pin.test.ts # packages/spec/variant-docs.json
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review over the prior at-tier record ① Derived judgmentsShape of the delta. (1) Behaviour unchanged — held, byte-exact. The (2) The two registration files — a clean union, nothing dropped, nothing doubled.
(3) Generated files equal the generators' output on the merged tree — judged by counts and by the gates.
(4) Nothing main landed was undone. Of the 25 files #23348 ( (5) CI on the head, read once at ② Semver levelUnchanged from the prior record. ③ Boundary flags
Implemented-by: VERDICT: PASS |
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
statusstays exactly as authored; the engine's verdict is served beside the item:GET /api/v1/meta/flow/NAME:getMetaItem): a new envelope keyregistration, a sibling oflock;metadata.enableCache: true): the sameregistration, read live on every call (claim amendment6157354342).getMetaItems/GET /api/v1/meta/flow): a new top-level keyregistrations, keyed by flow name, besideitems. The REST list chain keeps sibling keys (withItemsspreads 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:
stateregisteredrefusedreason= the thrown error's ownmessage, and its stringcodeonly when the error set one (verbatim, never paraphrased, never invented)unregisteredrefusedrequiresreason(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
unknownwould be a verdict nobody made. Both schema members document absence as "no engine judged it, neverregistered". The same absence applies to every type other thanflow, 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 readscanonicalizeStoredFlowfrom the same service the same way, for the save door and the stored migration. The member is declared on the existing contractIAutomationServicein@objectstack/spec/contracts(getFlowRegistration?(name), optional, besidecanonicalizeStoredFlowandgetFlowRuntimeStates), whichAutomationEngineimplements; the automation plugin registers the engine itself underautomation.metadata-protocolimports nothing fromservice-automation.The REST cached branch reaches the same seam through the protocol.
getMetaItemCachedalready performs the uncached read on every call (it is a validator wrapper, not a store), so it returns that read's liveregistrationbesidedata.GetMetaItemCachedResponseSchemadeclares it, andrest-server.tsspreads it into the cached envelope forflowonly.The default cached branch: live, not stored, not in the ETag
getMetaItemCachedhashes the document alone, as before. The verdict is not stored anywhere and is never part of the ETag.registrationis never answered304 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 traderest-server.tsrecords fordashboard). An unjudged flow (no automation service) keeps its 304 exactly as before.Where the verdict is recorded (
service-automation)kernel:readycold-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.metadata:reloadedre-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 (registerFlowthrows before it changes anything), so that name staysregistered.start(). The bind re-judges every flow of the view atkernel:ready, so a record made there is a verdict this same boot can contradict (AGENTS.md "Startup registry reads").check:startup-registry-verdictis green.registerFlowof the name and bywithdrawFlow.recordFlowRefusalignores a name the engine holds, so a name is neverrefusedand 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).sys_metadataflow rows land after ObjectQL started, so the boot pull never sees them. The node type's executor registers from a later plugin'sstart()(the automation: an approval node's escalation values are checked only at execution — timeoutHours 0.5 registers and activates, then every run fails and the record is created with no approval gate #21848 harness shape), so thekernel:readybind refuses the row whose plugin node carries an undeclared key.item.status === 'active'besideregistration: { state: 'refused', reason: { message } }. The message equals whatregisterFlowthrows for that body, with nocode(a plain error).registeredfor the control.{ state: 'registered' }, its status as stored.registrations, keyed by name, with the items bare.registered).packages/rest/src/meta-flow-cached-registration.test.ts(new): the default door.getMetaItemCachedcalled,getMetaItemnot).registration: refusedwith its reason beside it.{ type, name, item, registration: registered }.packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts: nine cases on that file's already-pinned engine double (itsharnessgains an optional service table).lock, never insideitem.view, and the engine is never asked.packages/spec/src/api/meta-item-response-shapes.test.ts:registrationis the verdict or undefined;registrationsis a name-keyed record of verdicts or undefined;refusedare 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 feat(cli,spec): a host declares the ADR-0131 D10 fates of its own objects through os migrate organization-ownership --fate-supplement #23348's seven pins, the count is 783 to 785.packages/spec/variant-docs.jsonbinds the three states to the flows page (check:variant-docs).Ablations (
scripts/ablation-replace.mjs, wrap mode, subject imported from source in both, so nodist/sits on the path):recordFlowRefusalcall was replaced by a marker comment: anchor 1 to 0, marker 0 to 1, blob29d687232ea8tocba906b7c690.{ state: 'unregistered' }, and so did the list entry.29d687232ea8, andgit diff HEADis empty....cachedRegistrationwas replaced by a marker comment: blob41d526941b28to0e5e6328b07b.registration, and the registered control lost it. The other-type control stayed green.41d526941b28, andgit diff HEADis empty.Verification (local, shared box; CI runs the full battery)
Final head
93a72727a: this branch merged withorigin/mainat6d96721fa, then at18cd410dc(ba00d5942, viascripts/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, andcontent/docs/references/index.mdxwas regenerated (93a72727a). The contract review PASSed on2b84fe555(6159075428), and the delta review of the merge PASSed on93a72727a(6159900532).@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.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'sdist/, and a fullpnpm buildwas not run on the shared box.check:type-check-debt: its--re-measurerebuilds and type-checks the whole workspace, past the foreground cap. A first attempt was cap-killed and left half-builtdist/s in this worktree, which were rebuilt beforecheck:dts-closurewas re-run green.os-verify-lock):8f39c942f(after the first main merge, before the cached-branch widening):@objectstack/service-automation186 files / 2453 tests;@objectstack/metadata-protocol238 files / 28339 tests (+19 skipped);@objectstack/spec(local project) 669 files / 20278 tests;9c498cda5(after the widening):@objectstack/rest(local project) 280 files / 5839 tests (+326 skipped), plus the fivegetMetaItemCachedfiles ofmetadata-protocol, 132 tests.origin/mainchanged 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).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):
lock,version,editable,deletable,resettable, provenance). This is pre-existing and already declared inGetMetaItemResponseSchema's docblock, which leaves it open (the sweep of 读侧的 ADR-0008 OCC 载体没有契约:GET /meta/:type/:name非缓存分支发lock/lockReason/lockSource,GetMetaItemResponseSchema只声明{type,name,item}#5950). Per the amendment it is not this card's. Carrier: thedomain:specseat.7578323. No console surface reads the newregistration. Today a refused flow shows as:packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:6412: the open flow's runtime row comes fromGET /api/v1/automation/_status. A refused flow has no engine row (null), so the header pill and the Problems panel readunpublished("Unpublished draft", amber). That is the wrong reason, though not "running".: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_statusnot read yet) it falls back to the storedstatus, so a refusedactiveflow rendersenabled.registerFlowthen refuses, the engine runs the older body while the read serves the newer one besideregistration: registered. Whether a door accepts such a body was not measured. Carrier: none.pnpm check:type-check-debtruns unlocked as acheck:*gate, but its--re-measurebuilds workspacedist/s. When cap-killed mid-run, it leaves packages with JS deleted and declarations stale (tsupclean). 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