Skip to content

fix(automation,metadata-protocol,rest,spec): a stored flow the engine refuses is served beside its verdict, not as running - #23358

Merged
objectstack-fleet[bot] merged 16 commits into
mainfrom
claude/issue-23316-refused-flow-runtime-state
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 16 commits into
mainfrom
claude/issue-23316-refused-flow-runtime-state

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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 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 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 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.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 读侧的 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: 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

claude added 14 commits October 13, 2026 19:41
…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>
…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-protocol, @objectstack/rest, @objectstack/service-automation, @objectstack/spec, touching 26 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via AutomationServicePlugin (symbol, a top-level class), registerFlow (symbol, a method of class AutomationEngine), /meta/flow/:name (route, a path literal in a comment on a changed line))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/data-modeling/drivers.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/kernel/runtime-services/data-service.mdx (via /api/protocol.zod (route, a path literal on a changed line))
  • content/docs/kernel/services-checklist.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation), getMetaItemCached (symbol, a method of class ObjectStackProtocolImplementation), /api/protocol.zod (route, a path literal on a changed line))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class), ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), IAutomationService (symbol, a top-level interface), ObjectStackProtocolImplementation (symbol, a top-level class), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-4.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class), AutomationServicePlugin (symbol, a top-level class), /meta/flow/:name (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-7.mdx (via IAutomationService (symbol, a top-level interface), registerFlow (symbol, a method of class AutomationEngine), /meta/flow/:name (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 18cd410dc4053cf8983993104e325f0bbef19258 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 34b0e4901de4bec3e596fd7e69a63ae5c626da6c — the merge of head 93a72727a561114fadc0693c491962d04431f4fc into base 18cd410dc4053cf8983993104e325f0bbef19258, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 18cd410dc4053cf8983993104e325f0bbef19258 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2b84fe5552ad42271d6ff4e12dcb9f1425abb208
Local-runs: none

Inputs read: card #23316 (body; triage 6152874675, claim 6153420029, amendment 6157354342, seat 2's pointer 6157680240, the dev report 6158808801, the seat ACCEPT 6158838703, the last as a claim under test, not evidence); PR #23358 (body, the one bot comment, 22-file list, net diff against main); the check-runs on the head; origin/main at 707b3cd91e4cf33c3d0d46b012852e25beb1360c (merge base 6d96721faac1f0e510b386644fc33b7f4fcbecf8); #23317's branch head ac768f1d54f1225d9cb6e64b581ca0fe8312774d; objectui on disk at 7578323, read only. Every tree read was by sha (git show SHA:PATH, git grep … SHA). Nothing was built, run or checked out.

① Derived judgments

(a) The ruling, the premise, the ban — held.

  • Envelope sibling of lock, never inside item. getMetaItem's final envelope spreads registration beside lock / editable / version (protocol.ts about :11153); the REST cached branch spreads it into the { type, name } envelope, and translateMetaEnvelope (meta-item-read-gate.ts:3062) spreads the envelope and puts the document under item, so it cannot land inside the body. Pinned on the item read (read.item has no registration), the cached read and the list.
  • The closed set is total and real. FlowRegistrationSchema is a discriminatedUnion('state') of exactly registered, refused (with reason REQUIRED, { message, code? }) and unregistered; a fourth state and a reason-less refused are pinned refused. AutomationEngine.getFlowRegistration is total over its own state: flows.has(name) → registered, flowRefusals.has(name) → refused, else unregistered. So unregistered is a real engine answer — the complement, not a made-up third state — and reason is the thrown error's own message (and its string code only when set), copied verbatim; the integration pin asserts byte equality with what registerFlow throws for the same body.
  • Absence documented as "never registered". Both schema members' docblocks and .describe strings, flows.mdx and the changeset say so. Absence is produced where ruled: no automation service or none declaring the member (resolveFlowRegistrationReader returns undefined), every non-flow type (canonicalMetaType(type) !== 'flow'; REST additionally guards metaType === 'flow'), a draft read (both the readState === 'draft' return and the previewDrafts draft-hit return precede the final envelope; the list skips _draft: true), and the execution face (audience !== 'served'). Each is pinned on the protocol double.
  • The seam is existing, with the named precedent. this.getServicesRegistry?.().get('automation') is the constructor-supplied accessor (protocol.ts:6644), read lazily per call exactly as resolveFlowCanonicalizer (:6537) reads canonicalizeStoredFlow. packages/metadata-protocol/package.json on the head declares no service-automation dependency, and git grep service-automation over packages/metadata-protocol/src (tests excluded) hits only three comments. The member lives on the existing IAutomationService contract, optional, beside canonicalizeStoredFlow and getFlowRuntimeStates; the engine registers itself under automation (plugin.ts:975).
  • The bans hold. The diff touches no write path in protocol.ts (reads only), no status enum, no stored row; engine.ts / plugin.ts add in-memory records only. The warn line [Automation] cold-boot flow bind: failed to register flow and 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 withdrawFlow call are context lines, byte-unchanged; the warn's level stays warn.

(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.

  • getMetaItemCached performs the delegate getMetaItem on EVERY call (it is a validator wrapper, no store) and takes registration from that result; the hash is over JSON.stringify(item) plus the locale only, so the verdict is in no validator. The If-None-Match arm is gated && registration === undefined, so a judged read is never answered notModified; the return adds registration only when defined. Pinned: same ETag with and without an engine; judged read not 304, unjudged control still 304.
  • At REST, for a flow the mask posture is OBJECT_SCHEMA_MASK_NOT_APPLICABLE (kind: 'passthrough', no runtime), intakeFolds and audienceFolds are false, so If-None-Match is handed to the protocol (which ignores it for a judged flow) and REST's own folded-ETag 304 comparison does not run. cachedRegistration is spread into both res.json exits of the branch (the undetermined-posture exit and the normal one); the 304 exit cannot be reached with a verdict present. Nothing is stored anywhere. No stale verdict can be served.
  • Unjudged flows and every other type: registration is undefined at the protocol (non-flow types never resolve a reader) and cachedRegistration is {} at REST, so their 304 behaviour and their cached envelope are byte-identical; pinned at REST (page/home).
  • GetMetaItemCachedResponseSchema is now MetadataCacheResponseSchema.extend({ registration: optional }): additive, optional, every existing key and default untouched; the generated reference shows the one added property. Correct.

(d) The list — as ruled. getMetaItems returns { type, items, …registrations } with BARE items (flowRegistrationsOf reads the names of governed and mutates nothing); one entry per listed item whose name is a string and which is not _draft: true; served audience only. createMetaListAnswer is the one chain both transports call (rest-server.ts:6106, runtime/src/domains/meta.ts:587), and every step rewraps through withItems, which spreads the raw envelope — sibling keys survive on both. The per-caller list gate judges app / dashboard / view / object / book / doc and returns every other type as given, so flow lists are unaffected. The list route reads getMetaItems directly (no cached list branch), so the default door serves registrations.

(e) Pins — the real composition, with controls; ablations directionally consistent, one count not. flow-refused-registration-read.integration.test.ts boots ObjectKernel + ObjectQLPlugin (its metadata protocol on driver-sql / better-sqlite3 :memory:) + AutomationServicePlugin, lands two sys_metadata rows and the node executor from a later plugin's start() (after the boot pull), so the kernel:ready bind is the judge; it pins the item read, the cached read, the list, a registered control, and a corrected save clearing the refusal. The REST pin asserts the cached branch (getMetaItemCached called, getMetaItem not) with registered and other-type controls; the protocol double pins no-service, no-member, view, draft, execution-face and 304 controls. Ablation 2 (REST spread removed: 2 red / 1 green over a 3-case file) matches the tree. Ablation 1 (bind recording removed: "2 failed / 2 passed") does NOT match the 5-case file on the head, where three cases read the refusal (item, cached, list) — see ③.

(f) Semver and docs — true and minimal. See ②. flows.mdx ("Discovery & Registration") states the three states, the ending of refused, absence, the live-read / never-304 behaviour and the per-process scope, each true of the code. variant-docs.json binds state:refused|registered|unregistered to flows.mdx in the existing key convention; the alias pins move 776 to 778 with both prose counts updated; the generated references (134 to 136 protocol schemas, 1524 to 1526 total, 434 to 436 API) and the strictness count (438 to 442: the refusal object plus three union arms) are consistent with the four new z.object sites. Nothing beyond what the gates owe.

(g) Overlap with #23317 (ac768f1d5) — textual adjacency, no semantic collision. #23317 edits engine.ts (registerFlow's four validate calls become a registrationChecks loop that throws new Error(refusal.message); canonicalizeStoredFlow splits into canonicalizeFlow plus a lazy todos getter) and automation-service.ts (the canonicalizeStoredFlow docblock only, lines 802 to 825). It touches no plugin.ts, protocol.ts or protocol.zod.ts. #23316's hooks sit at registerFlow's success tail (flows.set then flowRefusals.delete), in withdrawFlow, and in two new members — none moved by #23317. Because #23317 keeps registerFlow throwing the same message (its changeset says so; the checks threw plain Errors before and new Error(message) after, neither with a code), the verbatim reason this PR records is unchanged whichever lands second. Seat 2 already renamed its refusal types apart from FlowRegistration (377dc370b). Whichever lands second re-runs the automation suite, as 6157680240 records.

(h) CI on the head, read once at 2026-10-14T01:47:02Z. 44 check runs: 33 success, 8 skipped (the opt-in and path-filtered gates: Console Pin Gate, Dogfood Verify CLI, the dogfood matrix shards, Temporal Conformance, Packed-tarball smoke, and the duplicate Auto Label / Check PR Size entries), none failed, and three in progress at read: Test Core (1/6), Test Core (3/6), Test Core (4/6) — the only non-green checks. Test Core (2/6), (5/6) and (6/6) are green; so are Build Core, Build Docs, every Lint · lane, Type Check · source gates, · consumer gates, · workspace, · debt ledger and the TypeScript Type Check aggregator, Check Changeset, Spec property liveness, Governed Surface Queue Guard and the claim guards. The gates the dev left NOT MEASURED: check:dual-build-cjs-loads is an unconditional step of Build Core (ci.yml:2643, no if:), which ran and is green — measured by CI; check:type-check-debt is the re-measure step of Type Check · debt ledger (lint.yml:6152, continue-on-error behind a Lane verdict step that keeps a red gate red), which ran and is green — measured by CI. scripts/build-schemas-check-mode.test.ts is in packages/spec/vitest.repo-tests.json (the 59-file repo project), which Test Core runs whole through turbo run test test:repo; whether it ran in full is decided by the shard that carries @objectstack/spec, and three shards were still in progress at read, so it is not yet confirmed by CI at this read. This verdict is conditioned on those three shards landing green; a red one re-opens it.

② Semver level

.changeset/23316-refused-flow-registration-verdict.md: @objectstack/spec, @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/rest — all minor, with Clause-②: yes (widening). Correct: the repo is in changesets pre mode (.changeset/pre.json: mode: pre, tag next; all four at 18.0.0-next.0), AGENTS.md rules yes takes at least minor, and each package publishes something new — spec: four exports and three optional response fields plus an optional contract member; metadata-protocol and rest: a served field; service-automation: two public engine members. No arm narrows, nothing is removed or renamed, so no ADR-0087 disposition is owed. Every sentence of the changeset is true of the head.

③ Boundary flags

  1. Ablation 1's count predates the cached-read pin. The report's "2 failed / 2 passed" fits a four-case file; the head's integration file has five cases, three of which read the refusal. The direction (recording removed turns the item read and the list red) is proven and the cached read is covered by ablation 2 and by its own assertion against the real producer, so the evidence stands — but the count is not the head's. A re-run at the head would read 3 red / 2 green.
  2. A refused flow's record outlives its row. The bind deletes a refused name from syncedFlowNames, so when that row is later deleted the mutation sync's delete arm continues before withdrawFlow, and flowRefusals keeps the entry. No reader sees it (the item read 404s, the list keys only listed items) and re-creating the name clears or re-records it, so it is a bounded leak of one string per deleted refused flow. Noted, not blocking.
  3. rollbackFlow is a flows.set site that does not clear flowRefusals. Unreachable for a refused name today (no version history survives a withdrawal or a refusal), so no defect; the next flows.set site must clear the record or route through registerFlow.
  4. registered names a registration, not the served body (the dev's out-of-scope finding, read as pre-existing). On a refused re-save of a held flow the engine keeps the older body and the read serves the newer one beside registered. Pre-existing keep; the registered arm's docblock could say "a registration under the name, not necessarily this body". Seat's call; no door measured to accept such a body.
  5. Declared deviations, all inside the claim's lines: the contract member (one of the premise's seam forms), GetMetaItemCachedResponseSchema + getMetaItemCached (the amendment's "same seam"), variant-docs.json and the alias pins (gate-owed), recording widened to the reload and mutation syncs (same files, the boot pull deliberately excluded), and option A for 304s (the seat answered A in 6158838703; the amendment's "never part of the ETag" is kept literally). No stored-row stamp, no new status value, no field inside item.
  6. objectui follow-up (H5), confirmed read-only at 7578323: flow-problems.ts:375 deriveFlowRunStatus falls back to the stored status with no runtime reading, so a refused active flow renders enabled; StudioDesignSurface.tsx:6412 feeds the _status row (null for a refused flow, read as unpublished). No console surface reads registration. The seat files it, per the claim.
  7. The cached branch's missing protection envelope (lock, version, editable, …) is pre-existing and declared open in GetMetaItemResponseSchema's docblock; not this card's, per the amendment.
  8. The per-caller list gate and registrations: withItems keeps the map while the gate prunes items, which is moot today (flow is ungated) but a future flow list gate must prune registrations to the served items.

Implemented-by: claude/issue-23316-refused-flow-runtime-state
Reviewed-by: session_012uFegdfR6K6uqvLzt7XsdZ

VERDICT: PASS

…fused-flow-runtime-state

# Conflicts:
#	packages/spec/src/type-alias-convention.pin.test.ts
#	packages/spec/variant-docs.json
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 93a72727a561114fadc0693c491962d04431f4fc
Local-runs: none

Delta review over the prior at-tier record 6159075428 (PASS on 2b84fe5552ad42271d6ff4e12dcb9f1425abb208, read in full; its judgments (a)–(h) and flags 1–8 stand and are not re-rendered). Scope: only what changed between 2b84fe555 and 93a72727a beyond main's own content, and whether the PR's behaviour is byte-unchanged. Inputs read: PR #23358 (body, both comments, the 22-file net diff against main); card #23316's six comments (nothing posted after the seat ACCEPT 6158838703); the check-runs on the head, read once at 2026-10-14T02:53:54Z; read-only git by 40-hex sha — the prior base 6d96721faac1f0e510b386644fc33b7f4fcbecf8, main at 18cd410dc4053cf8983993104e325f0bbef19258 (the merge's second parent; git merge-base of head and main), the merge commit ba00d5942475c6eb4d0adebf7cb29b69cbd4fef6, #23348 at 707b3cd91e4cf33c3d0d46b012852e25beb1360c. Nothing built, run, re-run or checked out.

① Derived judgments

Shape of the delta. 93a72727a = ba00d5942 (a true merge: parents 2b84fe555 and 18cd410dc, both verified as ancestors) + one regen commit whose whole diff is content/docs/references/index.mdx (5 insertions, 4 deletions). main landed ten PRs in 6d96721fa..18cd410dc (#23303, #23313, #23349, #23326, #23346, #23257, #23353, #23348, #23345, #23283); #23317 is not among them. The PR still touches no governed surface (Governed Surface Queue Guard green).

(1) Behaviour unchanged — held, byte-exact. The +/- lines of git diff 6d96721fa 2b84fe555 and of git diff 18cd410dc 93a72727a, per file with hunk headers and context stripped, are identical for 20 of the 22 files: protocol.ts (81 lines), rest-server.ts (17), engine.ts (59), plugin.ts (17), protocol.zod.ts (111), automation-service.ts (26), the changeset (15), flows.mdx (18), protocol.flow-by-name-shipped-name.test.ts (159), meta-flow-cached-registration.test.ts (121), flow-refused-registration-read.integration.test.ts (196), meta-item-response-shapes.test.ts (53), variant-docs.json (8), references/api/protocol.mdx (125), the strictness ledger api.md (2), and the five packages/spec/*/api.json projections (4 / 5 / 4 / 4 / 2). The only two files whose lines differ are the hand-kept alias pin file (16 lines before, 17 after — judged in (2)) and the generated index.mdx (10 and 10, different numbers — judged in (3)). No difference to name on any non-generated, non-registration file. No conflict marker in any of the 22 files on the head. main's own protocol.ts change (#23345) survives: the head-versus-main diff of that file is exactly the PR's 81 lines.

(2) The two registration files — a clean union, nothing dropped, nothing doubled.

  • packages/spec/src/type-alias-convention.pin.test.ts: export type Iso_ lines on the head = 785 (main 783, baseline 778). The PR's Iso_api_protocol__FlowRegistrationRefusalSchema and Iso_api_protocol__FlowRegistrationSchema and main's seven Iso_system_fateSupplement__* pins are each present exactly once; M189 is imported once; sort | uniq -d over every Iso_ name finds no duplicate. The header reads "785 isomorphic aliases", the case title "still declares all 785 isomorphic pins", the assertion toHaveLength(785). The receipts: main's "776 → 783 is the host fate supplement … +7 added" verbatim, followed by "783 → 785 is automation: a stored flow the engine refuses at cold boot is withdrawn with a warn, while the metadata read keeps serving it as status: 'active' — after the v18 upgrade, every refused flow reads as running #23316 … +2 added (landed beside the fate supplement's +7; both counted from the same base of 776)" — arithmetic correct (776 + 7 + 2) and the parenthetical follows the file's own convention for parallel-authored entries (the 784 → 787 / 787 → 789 pair records the same situation the same way). The head-versus-main diff of this file is exactly the PR's two pins, the three count edits and that receipt.
  • packages/spec/variant-docs.json: 21 entries on the head (main 20, baseline 19). Every entry of main is byte-equal on the head, the fate:attribution|column-drop|mirror-deletion|report and kind:holder|parent|subject entries included; the PR's state:refused|registered|unregistered entry is byte-equal to the baseline's and appears once, appended after main's kind: entry; no duplicate key; $comment identical to main. Nothing from either side missing.

(3) Generated files equal the generators' output on the merged tree — judged by counts and by the gates.

  • index.mdx on the head: 1533 schemas across 197 pages; API 32 pages / 436; System 36 pages / 284, with main's fate-supplement.zod.ts row present. Arithmetic: 1524 base + 7 (main's fate supplement) + 2 (this PR's FlowRegistration, FlowRegistrationRefusal) = 1533; baseline carried 1526 / 436 / 277 (35 system pages), main carries 1531 / 434 / 284. The head-versus-main diff of index.mdx is exactly the PR's own content: the description and total lines (1531 to 1533), the API row and header (434 to 436), and the protocol.zod.ts row gaining the two names. The regen commit's diff is generator-shaped only (count lines and one table row), not a hand edit.
  • Why a regen commit was needed: content/docs/references/** is routed merge=os-regen (.gitattributes:149), and the merge commit ba00d5942 held the PR's side of index.mdx (1526, without the fate-supplement row — the silent drop scripts/pm/os-regen-merge.sh's header documents as the reason for its fixed order). The regen commit restored main's row and the merged counts from the generator, which is the script's step 4 working as designed.
  • The other generated artifacts: the PR's lines in the five api.json projections, api/protocol.mdx and the strictness api.md are identical to the baseline's; main's system.json projections, system.md, references/system/fate-supplement.mdx, references/system/index.mdx, references/system/meta.json and packages/spec/llms.txt are untouched by the PR and byte-identical to main on the head.
  • CI coverage of "equal to the generator": Type Check · source gates — the job hosting check:generated --reconcile-only (lint.yml:5330), "Check the export-origins baseline resolves as recorded", "Check the authorable key surface is recorded and nothing vanished", "Check generated reference docs are in sync with the spec" (lint.yml:5470) and "Check generated skill references are in sync with the spec" — is green on the head; so are Build Core, the Lint & Repo Gates aggregator and Lint · docs & code guards.

(4) Nothing main landed was undone. Of the 25 files #23348 (707b3cd91) touched, only the three union or regenerated files above differ between 18cd410dc and the head; the named ones are blob-identical on both: packages/cli/src/utils/organization-ownership-inventory.ts (042f650ac), organization-ownership-plan.ts (2fc205989), organization-ownership-inventory.test.ts (c01b37cd2), organization-ownership-host-supplement.integration.test.ts (5f6292951), packages/spec/src/system/fate-supplement.zod.ts (78dd8b3d4), fate-supplement.test.ts (2ba528e3b), content/docs/references/system/fate-supplement.mdx (ec59e6afe) and .changeset/23280-host-fate-supplement.md (66488a6a3). Widened to every file main changed in 6d96721fa..18cd410dc (110 files): only four differ between main and the head — index.mdx, the pin file and variant-docs.json (the unions above) and protocol.ts (the PR's own 81 lines on top of #23345's). Nothing of main's is missing.

(5) CI on the head, read once at 2026-10-14T02:53:54Z. 41 check runs: 36 success, 5 skipped, 0 failed, 0 in progress. The skipped five are the opt-in and path-filtered lanes: Console Pin Gate, Dogfood Verify CLI, the unexpanded dogfood regression matrix shard entry, Temporal Conformance (live PG + MySQL), Packed-tarball smoke (opt-in). Non-green checks: none. Test Core (1/6) through (6/6) are all green (completed between 02:18 and 02:27), and the Test Core aggregator is green — the prior record's condition (three shards in progress) is discharged on this head, which also closes the dev's one NOT-MEASURED test file, since @objectstack/spec's test:repo ran whole in the shards. check:dual-build-cjs-loads is an unconditional step of Build Core (ci.yml:2643): green. check:type-check-debt is the re-measure step of Type Check · debt ledger (lint.yml:6152): green. Also green: Build Docs, every Lint · lane, Type Check · source gates / · consumer gates / · workspace and the TypeScript Type Check aggregator, Check Changeset, Spec property liveness, Governed Surface Queue Guard, the claim guards, Auto Label, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Dogfood Regression Gate, filter.

② Semver level

Unchanged from the prior record. .changeset/23316-refused-flow-registration-verdict.md is byte-identical across the merge (its 15 lines identical in both ranges): @objectstack/spec, @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/rest at minor, Clause-②: yes (widening). The merge added no package to the PR's diff, moved no arm and removed nothing; main's own #23348 changeset is separate and blob-identical. Check Changeset green.

③ Boundary flags

  1. The PR body is stale on two sentences after the merge: "Final head 2b84fe555 = this branch merged with origin/main 6d96721fa" and the alias-pin "count 776 to 778" (now 783 to 785 on the head). Prose only — this record's Head-sha governs — and the seat may ask for a one-line body touch-up or leave it.
  2. The intermediate merge commit ba00d5942 is not a landable state on its own (index.mdx there lacks main's fate-supplement row, the os-regen driver's documented drop); the head is. Nothing to do — the queue lands the head — but a reader walking the branch commit by commit should not read it as a dropped advance.
  3. The prior record's flags 1–8 carry over unchanged: the PR's +/- lines are byte-identical, so nothing in this delta touches the ablation count, the refusal-record leak on row delete, the rollbackFlow site, the registered docblock precision, the declared deviations, the objectui follow-up, the cached branch's missing envelope, or the future flow-list gate.
  4. v18 stored preview: os migrate meta --stored still leaves out two families the v18 doors refuse — page components binding the retired API data source, and registerFlow's other refusals #23317 overlap still pending: it did not land in 6d96721fa..18cd410dc, and this merge changed none of the lines it is adjacent to (engine.ts, automation-service.ts carry this PR's identical lines). Whichever lands second re-runs the automation suite, per 6157680240.
  5. index.mdx is a merge=os-regen path; the regen commit is the generator's output and the generated-reference gate is green, so no hand edit is suspected.

Implemented-by: claude/issue-23316-refused-flow-runtime-state
Reviewed-by: session_012uFegdfR6K6uqvLzt7XsdZ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 03:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 03:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit d89f491 Oct 14, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23316-refused-flow-runtime-state branch October 14, 2026 03:33
This was referenced Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants