Skip to content

fix(cli)!: os validate and os build refuse a flow whose region the automation engine refuses to register - #29043

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-1minft
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-1minft

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

Requested by Leehom · project thread

Fixes #28893

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches what os validate / os build refuse about flow regions; the region rules themselves are unchanged.

维护者速读

  • 问题:os validate 和 os build 放行一个 try_catch / loop / parallel 区域不是单入口单出口(或单分支 parallel)的 flow,build 还照常产出 artifact;引擎在 registerFlow 里用 validateControlFlow 拒绝它,启动只记一条 [Automation] failed to register flow 警告,然后不带这个 flow 继续运行,触发器从不触发。
  • 现在:两条命令对每个 flow 调用引擎注册时的同一判定(FlowSchema,再 spec 导出的 validateControlFlow),任意深度;拒绝信息就是引擎原文(<where>: <errors>)前面加 flow 名,退出码 1,build 不写 dist/。规则没有第二份拷贝,未改 packages/spec/**。
  • 这是作者期的收窄(Clause-②: no (narrowing),标题带 !):新拒绝的每个 flow 本来就注册失败、从未运行,运行中的 flow 不受影响。
  • 未做(已列入 out_of_scope):os lint 仍看不见这个检查(parity 测试里以 blindTo: ['lint'] 钉住);顶层图的环(detectCycles 在 service-automation 内且未导出)validate 仍放行;启动横幅不计"注册失败"数,只在 Boot diagnostics 里逐条出现。

What changes

  • packages/cli/src/utils/flow-region-shape.ts (new): findFlowRegionRefusals(parsed) walks each packages[] entry's flows, or the stack's own flows when it declares no packages (the walk findExtensionFieldSuffixRefusals takes), and hands each one to the engine's registration verdict: FlowSchema.safeParse, then validateControlFlow from @objectstack/spec/automation. A refusal is a row { path, code: 'VALIDATION_ERROR', flow, message } whose message is Flow '<name>' would be refused at registration: followed by the engine's message verbatim. Like the engine it stops at a flow's first malformed region; every flow is judged. A flow the schema refuses is left to the schema step.

  • commands/validate.ts step 3a-vi and commands/compile.ts (os build) step 3b-iii-ter call it beside the __c reservation gate, ahead of every artifact write, with the same two faces as the gates around them: the command's --json failure envelope (errors rows), and the text face (printError + one bullet per flow, exit 1).

  • test/validate-build-gate-parity.test.ts: findFlowRegionRefusals joins SHARED_NON_REGISTRY_GATES, the ledger of gates both commands run.

Placement (A), in lane, following #28727: a cli/src/utils judge over the spec's exported function. No packages/spec/**, packages/lint/** or service-automation edit.

Tests

  • test/validate-flow-region-shape.test.ts (new, in-process Validate / Compile, 4 cases):
    • the duly shape (try_catch 'guard' whose catch region is d → x, y, z with no join): validate --json exits 1 with a row carrying try_catch 'guard' catch: region must be single-exit but has 3: x, y, z and the flow name; build --json exits 1, success: false, the same row, and no dist/;
    • nested (a loop whose body is that fan-out, inside a try_catch try region): the same, with try_catch 'outer' try → loop 'sweep' body: …;
    • single-branch parallel: parallel 'fan': a parallel block needs at least 2 branches, both commands;
    • control: the joined region (x, y, z → j) validates and builds.
    • On origin/main (ff881050): the three refused cases red (validate exited 0, build succeeded), the control green.
  • Ablations, each landed on disk then restored: the call in validate.ts replaced by [] → 3 red; the call in compile.ts replaced by [] → 3 red (expected +0 to be 1).
  • test/authoring-rule-command-parity.test.ts: per the seat's ruling, os lint's blindness is recorded as blindTo: ['lint']. It sits in a second table, OUTSIDE_THE_TABLE, not in CASES: every CASES row asserts the rule gates on all three commands through runAuthoringRules, and this check is not an authoring rule, so a CASES row would be red by construction. The new row ASSERTS the blindness (runAuthoringRules('lint', …) carries no single-exit finding), so closing the gap reds it and the case moves into CASES.
  • One os validate run (real CLI, text face) over the duly fixture:
base (ff881050)                              head
  → Running author-time rules (60)...          → Running author-time rules (60)...
  → Checking that every required capability…
  → Checking package docs (ADR-0046)...        ✗ The automation engine would refuse to register 1 flow for a malformed region (ADR-0031)
  ✓ Validation passed (166ms)                  • flows[0] [VALIDATION_ERROR] Flow 'rgn_lead_time' would be refused at registration:
  Logic: 1 Flows                                 try_catch 'guard' catch: region must be single-exit but has 3: x, y, z — add one node
exit 0                                           with an edge from each of x, y, z to it, so it is the region's only exit; …
                                             exit 1

Acceptance notes

  • Clause-② reading. The order asked for yes (narrowing) if the gates read it so. clause2-line.mjs reads yes (narrowing) as "widens one surface and narrows another"; nothing here widens (no new export, schema key or accepted input; flow on the error row is a new member of an existing row list, like rule / packageId on its neighbours). So the line is no (narrowing), the spelling cli: os build does not run the __c extension-field judge that os validate runs (#28564 follow-up) #28727 used for the same kind of change; the changeset carries the **BREAKING** banner and an ADR-0087 not-required disposition.
  • Boot side (ruling 3), measured, not changed. The banner's Flows: line (utils/format.ts printAutomationSummary, ~1718) counts registered flows (flowCount = getFlowRuntimeStates().length), bound flows and drafts. declaredFlowCount (commands/serve.ts ~5708, resolveStackCollection(config, 'flows')) is read only for the engine-disabled line. A refused flow is in neither count; it surfaces only as its [Automation] failed to register flow line in Boot diagnostics. A refused count would be one more parts.push beside the draft count, but no one-line, same-file version is exact: declaredFlowCount - flowCount is moved by runtime overlay rows and same-named contenders too, and the engine exposes refusals only per name (getFlowRegistration(name)), so an exact count needs a new summary field collected in serve.ts. Left out, reported. Whether boot continues is untouched.
  • Not closed here, reported: the lint-rule parity (os lint blind, B, domain:devx), and a cycle in the flow's TOP-LEVEL graph, which os validate still passes (start → a → b → a → end, exit 0): the engine refuses it with detectCycles (packages/services/service-automation/src/flow-registration-checks.ts), which is exported from that file but not from the package index. Region bodies are cycle-checked, by validateControlFlow.
  • Seat 3's files (subflow-guards.ts, region-suspension-refusal.ts, the arming gate) are untouched.

Verification

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 62 commands on the diff; all 62 exit 0, and --ran reconciles 62 derived, 62 run, 0 NOT-MEASURED. pnpm --filter @objectstack/cli typecheck (runs check:test-typecheck): exit 0. pnpm --filter @objectstack/spec check:error-code-provenance: exit 0. Scoped eslint over the six changed .ts files: exit 0.

pnpm --filter @objectstack/cli exec vitest run --project unit, after merging main (00d0668d, nothing under packages/cli, packages/spec or packages/lint): first run 368/369 files, 1 red: validate-build-gate-parity.test.ts "every call site … is classified" named findFlowRegionRefusals. Fixed by adding it to SHARED_NON_REGISTRY_GATES (a gate both commands run); re-run on the final head: 369/369 files, 5201/5201 tests, VERDICT command-exit 0. The integration tier (where authoring-rule-command-parity.test.ts lives, for its spawn case) is declared to CI; its in-process rule cases, the new pin included, ran locally green (10 passed).

Dogfood battery (owed for the (narrowing) arm): after building the @objectstack/dogfood... closure (68/68 tasks), pnpm --filter @objectstack/dogfood test under the lock, VERDICT command-exit 0 · Test Files 383 passed | 3 skipped (386) · Tests 3163 passed | 28 skipped (3191). It ran on 832bd06d; the final head adds the ledger row in a packages/cli test file and one more merge of main (ab085a04: under packages/spec only shared/region-currency.* and a save-notice placement change with a FlowSchema comment, nothing the region check reads).


Generated by Claude Code

…ine refuses to register

Both commands now hand each flow to the automation engine's registration
verdict (FlowSchema, then the spec's validateControlFlow) through
utils/flow-region-shape.ts, and refuse it with the engine's message behind
the flow's name. Before, they passed a flow whose try_catch, loop or
parallel region was not single-entry/single-exit (or a single-branch
parallel), and boot logged a warning and ran without it.

os lint stays blind to the check; the parity test pins that gap.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ab085a048241b0bf1fde06696fc6cbf659a6707f.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 83 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 35 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 ab085a048241b0bf1fde06696fc6cbf659a6707f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 85acf69a38b53e04f8f8f09114c2c69e3fc453d5 — the merge of head d246cd7e24aeae540a86b3f09a72207a35e33d0f into base ab085a048241b0bf1fde06696fc6cbf659a6707f, 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 85acf69a38b53e04f8f8f09114c2c69e3fc453d5 && git checkout 85acf69a38b53e04f8f8f09114c2c69e3fc453d5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab085a048241b0bf1fde06696fc6cbf659a6707f d246cd7e24aeae540a86b3f09a72207a35e33d0f && git checkout -B drift-repro ab085a048241b0bf1fde06696fc6cbf659a6707f && git merge --no-ff d246cd7e24aeae540a86b3f09a72207a35e33d0f

node scripts/docs-audit/affected-docs.mjs --json ab085a048241b0bf1fde06696fc6cbf659a6707f

⚠️ 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 ab085a048241b0bf1fde06696fc6cbf659a6707f → 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

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

Contract-review-claim: session_01FEkQ5oc56xChD6Wrc4cGSt · head d246cd7e24aeae540a86b3f09a72207a35e33d0f · 2026-11-02T06:24Z · narrowing face (Clause-②: no (narrowing)), at CONTRACT_REVIEW_TIER.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d246cd7e24aeae540a86b3f09a72207a35e33d0f
Local-runs: none
Scope: packages/cli/src/commands/compile.ts, packages/cli/src/commands/validate.ts, packages/cli/src/utils/flow-region-shape.ts, packages/cli/test/authoring-rule-command-parity.test.ts, packages/cli/test/validate-build-gate-parity.test.ts, packages/cli/test/validate-flow-region-shape.test.ts + packages/services/service-automation/src/flow-definitions.ts (canonicalizeFlow), packages/services/service-automation/src/engine.ts (registerFlow), packages/services/service-automation/src/plugin.ts (the boot pull and resolveFlowContenders), packages/spec/src/automation/control-flow.zod.ts (validateControlFlow, parseFlowNodeRegions), packages/spec/src/automation/flow.zod.ts (FlowSchema), packages/spec/src/stack.zod.ts (flows, AssembledPackageBodySchema, assembledPackageBodyShape), packages/spec/src/conversions/apply.ts (applyConversionsToFlow), packages/cli/src/utils/artifact-packages.ts, packages/cli/src/utils/extension-field-suffix.ts, packages/core/src/stack-collections.ts, packages/cli/src/commands/serve.ts (the AppPlugin wrap), packages/cli/package.json (exports), packages/cli/src/index.ts, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, docs/adr/0031-advanced-flow-node-executors-and-dag.md, docs/adr/0087-metadata-protocol-upgrade-contract.md. The changeset is read under ② only. Card #28893 (body, triage, claim, dev report), PR #29043 (body, file list, net diff against main), the head's check-runs.

① Derived judgments

  • Accept-set change, @objectstack/cli os validate and os build: NARROWED — named right. A stack carrying a flow whose loop body, parallel branch or try_catch region is not single-entry/single-exit or is cyclic at any depth, or whose parallel has fewer than two branches, now exits 1 from both commands (VALIDATION_ERROR row, text face printError + bullets) and build writes nothing. Before, both passed it and emitted an artifact. The diff states exactly this; the changeset states exactly this.
  • The narrowing IS the engine's registration verdict, read in the live tree — right, with one direction of slack that is a miss, not an over-refusal. registerFlow (engine.ts) calls canonicalizeFlow (flow-definitions.ts), which is applyConversionsToFlow → FlowSchema.parse → this.detectCycles → validateControlFlow, BEFORE any status handling, so drafts and obsolete flows are judged the same way. The CLI runs FlowSchema.safeParse → validateControlFlow (the same spec export, same function) over result.data, the parsed stack. Three facts make the two inputs the same flow: compile.ts serializes finalBundle = { ...result.data } into dist/objectstack.json, so the body boot re-parses IS the CLI's parsed flow; the stack schema parses top-level flows as z.array(FlowSchema) (stack.zod.ts:872) and a package body's flows through assembledPackageBodyShape() from the same STACK_DEFINITION_COLLECTIONS_SHAPE, so both subjects are FlowSchema output; and the conversion chain is the ADR-0087 D2 rehydration seam that re-runs on every stored canonical body, so it is a no-op on canonical input by design. validateControlFlow is a pure function of nodes and stops at the first malformed region in both callers. Therefore every flow the CLI refuses is one registerFlow throws on, and no flow the engine registers is refused. The slack: the engine also refuses a cycle in the TOP-LEVEL graph (detectCycles, not exported from the package index) and a body FlowSchema.parse rejects after conversions; the CLI passes the first (the dev measured and reported it) and leaves the second to the stack schema (true for both subjects, as above). Both are misses in the false-clean direction the card already names, not refusals of anything registrable.
  • Every place a stack carries flows — right by the precedent's rule, with its one documented blind spot. findFlowRegionRefusals walks packages[i].manifest.flows when packages[] is non-empty, else top-level flows — the walk findExtensionFieldSuffixRefusals (cli: os build does not run the __c extension-field judge that os validate runs (#28564 follow-up) #28727, on main) takes. The runtime registers every package's flows (stack.zod.ts:2396) and, through the AppPlugin wrap in serve.ts, a host config's top-level collections; resolveStackCollection reads the top level as the union composeStacks flattened. A hand-written stack carrying BOTH packages[] and a top-level flows that is not that union has its top-level flows unjudged here — the same shape cli: os build does not run the __c extension-field judge that os validate runs (#28564 follow-up) #28727 accepted, a miss, not an over-refusal.
  • Public surface: unchanged — named right. @objectstack/cli publishes ., ./console, ./hook-body and package.json only (package.json exports); src/index.ts re-exports commands, no utils/*. findFlowRegionRefusals is a new INTERNAL module. The flow member on the error row joins a heterogeneous row list whose neighbours already carry rule / packageId; the --json envelope shape (valid/success, errors, warnings, conversions) is untouched. No spec, lint, metadata-protocol or service-automation file is touched; seat 3's subflow-guards.ts / region-suspension-refusal.ts are untouched.
  • The parity ledgers. findFlowRegionRefusals is called in both commands, so SHARED_NON_REGISTRY_GATES is the one honest row (the closed roster [finding] validate-build-gate-parity.test.ts says 「There is no third option」 but its detector matches only lint*/validate* call sites — a find*-named gate wired into ONE command passes today #18491 made would red any other placement, and did, per the dev's first run). The OUTSIDE_THE_TABLE row asserts that runAuthoringRules('lint', …) carries no finding whose message includes single-exit — true today and a real ratchet (a registry rule wrapping validateControlFlow reds it). What it does NOT assert is read under ④.
  • Tests. validate-flow-region-shape.test.ts: duly, nested, single-branch parallel and a joined control, in-process, exit code and dist/ absence asserted, each refusal matched on the engine's verbatim sentence plus the flow name. The gate-ablation and base-red claims are the dev's; the derived gate families on the head (Lint, Type Check, Test Core 3/7/8, Check Changeset, Governed Surface Queue Guard) are green, which is what this record reads.

② Semver level

  • @objectstack/cli: patch — right. Metadata-protocol: none is right: FlowSchema and the engine's registered set are unchanged, so what a metadata author writes against is unchanged; only the author-time door now agrees with the protocol it already fails against. AGENTS.md §3: a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition. Check Changeset is green on the head.
  • Clause-②: no (narrowing) — right, and the claim's expected yes (narrowing) was the wrong arm. scripts/pm/clause2-line.mjs reads yes (narrowing) as "widens one surface AND narrows another" and no (narrowing) as "NOT a widening, but breaking". Nothing here widens (①), so no (narrowing) is the only honest spelling. cli: os build does not run the __c extension-field judge that os validate runs (#28564 follow-up) #28727 (PR fix(cli)!: os build refuses a field the __c extension-field reservation refuses, as os validate does (#28727) #28757, merged as 850eab2a94, contract review PASS 6561884162) is the same kind of change — os build gaining a judge os validate already ran — and carried no (narrowing), patch, Metadata-protocol: none, the BREAKING banner and not-required (no-migration-prescription). Same line in the PR body and the changeset; PR title carries !.
  • BREAKING note — right. The banner names the two commands, the package, the exact shape newly refused, the before (passed and emitted) and after (exit 1, no dist/), that the registered set is unchanged, and the remedy.
  • ADR-0087 disposition / FROM → TO — not owed; not-required (no-migration-prescription) is the honest category. AGENTS.md §3 owes a FROM → TO mapping when the diff removes or renames something an author can write (a spec key, an export, a config field). Nothing authorable is removed, renamed or re-shaped: FlowSchema is byte-identical, no export moves, and every flow newly refused was already refused at registerFlow and never ran, so objectstack migrate meta has nothing to rewrite and no D2/D3 entry can exist for it. The ADR-0087 gate's own check on this category — the body must carry no migration prescription — holds: the body carries the engine's one-sentence remedy as prose, no before/after block. Lint · repo & tooling gates (where check:adr-0087-registration runs) is green on the head.

③ Boundary flags

  • Dev deviation 1 (no (narrowing) not yes (narrowing)): answered in ② — the dev is right, the order's expected spelling was the malformed-by-meaning one.
  • Dev deviation 2 (lint blindness in a second table, not CASES): answered — a CASES row asserts the rule gates on all three commands through runAuthoringRules and this check is not a registry rule, so a CASES row would be red by construction; the second table is the honest placement. The guard the guard test reads CASES only and is untouched.
  • Dev deviation 3 (one SHARED_NON_REGISTRY_GATES row): answered — required by the closed roster, right placement (①).
  • Dev deviation 4 (boot summary unchanged, ruling 3): answered — the order's file surface read "boot summary only per ruling 3"; the dev measured printAutomationSummary and declaredFlowCount and reported why no exact one-line count exists. Whether boot continues past a refusal is untouched and was not in the order. The card's second ask ("louder than a WARN") is therefore still open on the card — escalated to the seat: the dev's three out_of_scope_findings (lint blindness, top-level detectCycles at author time, a refused-flow count in the boot banner) are carried as "noted, not filed"; the seat files or declines them so the card does not close over them silently.
  • open_questions: none.
  • Check-runs on the head at read time: every concluded run is success (Lint & Repo Gates, TypeScript Type Check, Lint · ESLint, Lint · repo & tooling gates, Lint · PM dispatch-gates, Type Check · workspace, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, Build Core, Test UI, Live E2E UI, Test Core 3/8, 7/8, 8/8); Test Core 1/8, 2/8, 4/8, 5/8, 6/8 were still in progress. No run has failed. This record stands on the concluded runs; landing waits for the remaining shards as it always does.
  • Skills-docs: none — read against skills/**: no file teaches what os validate / os build refuse about flow regions; the region rules are unchanged. Holds.

④ Design judgment

The narrowing, read against the published spec on main: ADR-0031 declares a loop body, parallel branch and try_catch region as a single-entry/single-exit, acyclic region and declares the engine's duty to "reject the malformed before run"; parseFlowNodeRegions deliberately returns an unparseable region untouched so that validateControlFlow owns the verdict and its message. So the capability an author is declared to have is a WELL-FORMED region, and a multi-exit region was never declared, never registered and never ran. This diff takes no declared capability away at the consumer; it makes the spec's own refusal real at the two author-time doors, with the spec's own function and sentence, and it is not an implementation gap narrowed at the consumer (the engine already delivers the refusal; the gap was that the doors did not). Design holds. I tried to break it in four directions:

  1. A flow the engine registers that the CLI refuses. Needs the CLI's validateControlFlow input to differ from the engine's. Both are FlowSchema output of the same body (①); MAX_REGION_DEPTH, the parallel branch count and the region-schema refusal are inside the one shared function; the engine runs the function before any status or trigger handling. Not reachable from the artifact. One engine-side edge: two same-named packaged flows across packages are collapsed by resolveFlowContenders and the loser is never offered to registerFlow, so a stack whose SHADOWED copy has a malformed region boots (with a collision warning) while the CLI refuses it. That stack is already a name collision the protocol refuses on write (runtime automation.ts: a name two packages hold is refused) and the engine warns about at boot; refusing it at author time is in the card's spirit, not against it. Observation, no fix owed.
  2. A region shape the CLI refuses that validateControlFlow would not. The CLI adds no predicate of its own: it only re-labels the thrown message behind the flow name. Not reachable.
  3. The redundant FlowSchema.safeParse on an already-parsed flow. On result.data it can only fail if FlowSchema is not idempotent on its own output. Today it is (the engine re-parses stored canonical bodies at every rehydration, and the control case proves the round trip), and a failure returns silently — the FALSE-CLEAN direction this card exists to close, with no trace. Should-fix: do not return silently on a parse failure of a schema-parsed flow — push a row (or throw) naming the flow, so a future non-idempotency surfaces as a refusal rather than a pass.
  4. The pins. (a) The OUTSIDE_THE_TABLE row asserts only the negative half: that the registry carries no single-exit finding for the fixture. It never asserts the fixture trips findFlowRegionRefusals, so a fixture typo (a region that is in fact single-exit) leaves the blindness assertion vacuously green — the exact half-a-problem the file's own guard the guard comment warns about. Should-fix: assert findFlowRegionRefusals(testCase.stack) is non-empty (message carrying the marker) in the same case. (b) The row's docblock says "os lint passes it"; the pin measures runAuthoringRules('lint', …), not the os lint command, which could be hand-wired like validate/build and leave the pin green. The docblock should say what is measured (the registry's blindness). (c) validate-flow-region-shape.test.ts has no packages[] case: the packages[N].manifest.flows[i] subject and its path spelling, which the changeset and PR body both claim, are untested. Should-fix: add one assembled-artifact fixture (the precedent cli: os build does not run the __c extension-field judge that os validate runs (#28564 follow-up) #28727 tested both subjects). (d) The parity file's test-budget header reads "the nine rule cases run in process"; there are now ten. The gate reads the seconds only; the prose is stale. Trivial.

No must-fix. Four should-fix items above (③ carries the escalation); none changes the verdict.

Implemented-by: claude/project-thread-1minft
Reviewed-by: session_01FEkQ5oc56xChD6Wrc4cGSt

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

席位核对

ACCEPT · domain:server seat 5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt · 2026-11-02T06:38Z · head d246cd7e · #28893

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

席位意见:与 diff 相符。os validate 和 os build 对每个 flow 调用引擎注册时的同一判定(FlowSchema + spec 导出的 validateControlFlow),区域不是单入口单出口或单分支 parallel 时退出 1,build 不写 dist/;规则没有第二份拷贝,引擎注册集合不变。

  1. Shape: draft against main, Fixes #28893, patch changeset for @objectstack/cli with the BREAKING banner; Clause-②: no (narrowing), Metadata-protocol: none, Skills-docs: none. Title carries !. check-governed-merges --pr 29043: NOT governed, 7 paths, +381 / -0. git merge-tree against main: clean.
  2. Against the order:
    • Ruling 1 (placement A, a CLI gate). The gate is a new internal module, packages/cli/src/utils/flow-region-shape.ts, called from validate.ts and compile.ts ahead of every artifact write. No packages/spec/** or packages/lint/** edit.
    • Ruling 2 (the engine's verdict, not a copy). The check calls the spec's validateControlFlow on FlowSchema output, which is the function registerFlow reaches through canonicalizeFlow. The refusal text is the engine's sentence behind the flow name.
    • Ruling 3 (boot: measure and name). No boot change. The dev measured printAutomationSummary and reported that no one-line count exists. The count is filed as cli: harden the os validate/build flow-region gate (silent parse skip, vacuous blindness pin, untested packages[] subject) and count refused flows at boot #29059 item 5.
    • Ruling 4. Seat 3's subflow-guards.ts and region-suspension-refusal.ts are untouched.
    • Contract review: PASS on this head, narrowing face, comment 6570752949. No must-fix.
  3. Accepted deviations:
  4. Gates: red first on base, gate ablation red, derived gates exit 0 (dev report). Check-runs on this head: 33 success, 13 skipped, 1 still running at ACCEPT. Auto-merge waits on every required run.
  5. Out of scope: filed or already open, so the card does not close over them:
  6. Landing: pr_ready + automerge_enable through the relay.

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/m tests tooling

Projects

None yet

1 participant