Skip to content

fix(lint)!: refuse a flow whose loop, parallel or try_catch region is malformed at publish - #29526

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-qv2l74
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-qv2l74

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Requested by Steve · project thread
Fixes #29233

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — no published skill teaches that a malformed region publishes; the automation skill's region rules (one entry, one exit, acyclic) stay true, and the publish door now refuses a region that breaks them earlier, in the engine's words.

维护者速读

  • 改了什么:feat(lint)!: os lint refuses a flow region the engine refuses to register (flow-region-shape-invalid) #29495 新增的 lint 规则 validateFlowRegionShape(flow-region-shape-invalid)现在也在运行时发布门上跑:循环体、并行分支或 try/catch 区域写坏了的流程,草稿读取显示 valid: false,所有发布入口都拒绝发布。
  • 为什么改:以前这种流程草稿显示有效、发布成功,可引擎注册时拒收,流程永远不会运行,作者看不到任何错误。
  • 风险与回滚:只改了注册表里一行(surfaces: CLI_AND_RUNTIME、runtimeTypes: ['flow']),判官就是引擎注册时调用的 FlowSchema + validateControlFlow,没有第二份判断;回滚即撤销本 PR。
  • 先看:packages/lint/src/authoring-rules.ts 里 validateFlowRegionShape 那一行,再看 packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/runtime-authoring-gate.flow-region.test.ts,预期 8 个用例全绿(需先 pnpm --filter @objectstack/lint build)。
  • 需要你做的:无,席位落地。

What changes

  • @objectstack/lint: the registry row for validateFlowRegionShape (feat(lint)!: os lint refuses a flow region the engine refuses to register (flow-region-shape-invalid) #29495, rule id flow-region-shape-invalid) moves from CLI_ONLY to surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow'], and its surfaceReason is retired. The runtime authoring gate (evaluateRuntimeAuthoringGate), which the metadata draft read and every publish door run, picks it up through runRuntimeAuthoringRules and discloses it in rulesRun. No gate-local rule and no second call: runtime-authoring-gate.ts is untouched.
  • The judge is the one the engine's registration uses (canonicalizeFlow): FlowSchema, then validateControlFlow from @objectstack/spec/automation. The message is "Flow 'NAME' would be refused at registration: " followed by the engine's sentence, word for word.
  • Changeset .changeset/29233-flow-region-shape-publish.md: @objectstack/lint minor, BREAKING banner with before/now, adr-0087: not-required (no-migration-prescription).

Accepted differences from a gate-local rule (seat ruling)

  1. The finding is anchored on the flow (flows[i], where flow 'NAME'), so the draft read's path is the item root '', not the container (nodes.1). The sentence names the region. Finer location is lint: flow-region-shape-invalid anchors the whole flow; point the finding at the region container at fault #29558.
  2. The message carries the registry rule's prefix "Flow 'NAME' would be refused at registration: ".
  3. One finding per flow (the engine's first refusal), as the engine itself throws once.
  4. The publish doors judge the raw stored draft body; the engine converts first (applyConversionsToFlow). Landed as declared under seat ruling A; judging the converted body is server(metadata): the publish doors gate the raw stored flow draft, while the live save gates its D2-converted body (#21982), so a legacy spelling the engine registers is refused at publish #29557. Measurement below.

Tests

  • packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts (new, pure gate, 8 cases): the card's loop body a → b → a (pins the engine's sentence once; where flow 'rgnprobe_loop', path flows[0], rulesRun contains validateFlowRegionShape), a one-branch parallel, a try region that closes on itself, a region its schema refuses ("invalid region"), a malformed region nested in another (sentence names both levels), two malformed containers giving one finding; controls: well-formed loop / parallel / try_catch (nested too) raise nothing, a draft is not gated. Every expected message is validateControlFlow's own answer for the case.
  • packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts (real protocol doors): a CASES row for the card's loop (draft saves, draft read valid:false with the publish's own rule and message, per-item publish 422, package publish rolls back); a control that a well-formed loop, parallel and try_catch each read valid:true and publish (publishedCount: 3); a pin that the draft read carries the registry rule's message at the item root ('').
  • packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts (showcase boot, REST): the card's table (draft PUT 200, draft GET valid:false with code flow-region-shape-invalid, path "" and the engine's sentence, publish-drafts outcome: refused, no active row); the per-item POST /meta/flow/:name/publish and a live PUT each 422 with no active row; control: well-formed loop, parallel and try_catch each read valid, publish, and reach registration.state: registered.
  • Forced test edits (claim amendment 6585054494): packages/lint/src/runtime-gate.test.ts (brokenApprovalFlow / cleanApprovalFlow) and packages/lint/src/validate-flow-trigger-readiness.test.ts (apiFlow, startSecond). Their runtime-gate fixtures were flows the flow schema refuses (no flow label, unlabelled nodes, a top-level trigger key, no edges), which the engine never registers and which this rule now reports on the gate. The fixtures are made schema-valid; no assertion changed.

Registration / publish measurement (difference 4)

  • A stored body the flow schema refuses both raw and after conversion (no flow label, a node without a label, a top-level trigger key, an unknown flow type, edges missing) is refused by the engine's registerFlow too: the rule refuses nothing the engine would register.
  • A legacy D2 spelling (get_record config.filters) fails FlowSchema raw, passes once converted, and registerFlow REGISTERS it. Seeded as a stored legacy draft, it is refused at publishMetaItem and publishPackageDrafts with [expression-invalid, flow-region-shape-invalid]: expression-invalid already refuses it on main, so this PR adds no new refusal on that spelling.
  • NOT MEASURED: whether some other legacy spelling is refused by this rule alone. A new draft cannot carry a retired spelling (the draft save refuses it), so only rows stored before those retirements can reach this.

Acceptance notes

Verification

Head 2cb755ef (origin/main merged at f99a058, which carries #29495's merge 7f368f5).

Red first (tests on the main code)

On the earlier round the same door pins went red on main with the gate answering nothing (8 failed | 38 passed). On this head the ablation below puts main's registry row back (surfaces: CLI_ONLY) and the same 8 metadata-protocol cases fail, which is the red-first reading for the registry route.

Ablation (registry row back to CLI_ONLY, through lint dist)

scripts/ablation-replace.mjs on packages/lint/src/authoring-rules.ts (row back to surfaces: CLI_ONLY with a marked surfaceReason), pnpm --filter @objectstack/lint build, ablation-dist-preflight.mjs @objectstack/lint ABLATION_29233: marker present in 4 built files.

  • runtime-authoring-gate.flow-region.test.ts + protocol.draft-publish-verdict.test.ts: Tests 8 failed | 37 passed (45); the 8 are the six refusal cases of the pure-gate file and the CASES row plus the location pin; the controls and the draft case pass.
  • Dogfood file: Tests 2 failed | 1 passed (3); the card case and the per-item/live case fail, the control passes.
  • Restored: blob == HEAD (488c91a8), lint rebuilt, preflight --absent clean on all 20 built files, git status --porcelain empty.
Suites, gates, dogfood

All on head 2cb755e after pnpm install --frozen-lockfile and a full packages build (78/78 tasks).

  • pnpm --filter @objectstack/lint typecheck && test: Test Files 192 passed (192), Tests 7885 passed (7885).
  • pnpm --filter @objectstack/metadata-protocol typecheck && test: Test Files 308 passed | 4 skipped (312), Tests 22240 passed | 26 skipped (22266).
  • Lint: pnpm exec eslint --no-inline-config (the root pnpm lint command) on the six touched TS files, exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack listed 63 families; all 63 run, exit 0. --ran reconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero)".
  • Full dogfood battery (os-verify-lock.sh -c "pnpm --filter @objectstack/dogfood test"): VERDICT command-exit 0; Test Files 398 passed | 3 skipped (401), Tests 3280 passed | 28 skipped (3308).

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz

…region (red on main)

A flow whose loop / parallel / try_catch region the engine refuses to
register reads valid as a draft and publishes. These pins assert the
gate refuses it with the engine's sentence, on the draft read and at
every publish door, with well-formed region controls.

Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz
Co-authored-by: Claude <noreply@anthropic.com>
…th the engine's sentence

The runtime authoring gate now asks validateControlFlow, the judge the
engine's registration calls, on the body parsed through FlowSchema as
the engine parses it. A flow whose loop / parallel / try_catch region the
engine refuses reads valid:false as a draft and is refused at every
publish door, located on the container node at fault.

Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz
Co-authored-by: Claude <noreply@anthropic.com>
The control (three saves, three reads, a package publish, three
registration polls) timed out at the default 5 s inside the full
battery on a shared box; it passes alone.

Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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/lint, touching 3 documentable anchor(s).

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

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES))
What this run could not see
  • 1 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 — 4 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 2789ba7d4d4eebc135df3b053ff4c66a20d6c1f2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2789ba7d4d4eebc135df3b053ff4c66a20d6c1f2

⚠️ 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 2789ba7d4d4eebc135df3b053ff4c66a20d6c1f2 → 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#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 813305213fe74b9a078ab03c3fc8b5390c5af79d
Local-runs: none
Scope: packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts, packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts, packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts + packages/spec/src/automation/control-flow.zod.ts (validateControlFlow, collectFlowGraphs, regionSlotsOf, analyzeRegion, parseFlowNodeRegions, MAX_REGION_DEPTH), packages/spec/src/automation/flow.zod.ts (FlowSchema, the flow-wide duplicate-node-id refusal), packages/services/service-automation/src/flow-definitions.ts (canonicalizeFlow: applyConversionsToFlow → FlowSchema.parse → detectCycles → validateControlFlow), packages/metadata-protocol/src/protocol.ts (the live save door around flowGateVerdictBody / gatedItem, the per-item promotion on draftForGate.body, the draft read's draftPublishVerdict on storedRowDocument(row)), packages/metadata-protocol/src/protocol-authoring-gate.ts (judgeRuntimeAuthoringRules, draftPublishVerdict), packages/metadata-protocol/src/metadata-diagnostics.ts (itemRelativeIssuePath), packages/metadata-protocol/src/index.ts and package.json exports (public surface), .changeset/29233-flow-region-malformed-publish.md (declaration lines and level, read for ②), the precedent record 6574280356 on PR #29226 and the director ruling 6580265584 on #29025.

Face: narrowing (Clause-②: no (narrowing) line-initial on the PR body and the changeset; no path under packages/spec/src/** and no governed rules text in the committed tree). Every non-prose path of the diff is in scope above; the net diff against merge-base 94d3e2543 is 5 files, 562 insertions, 2 deletions, and packages/metadata-protocol/src/protocol.ts (frozen by seat 5 for #25467) is not among them.

① Derived judgments

  1. Accept set of the runtime authoring gate narrowed (@objectstack/metadata-protocol): a flow body that FlowSchema parses and whose loop body, parallel branch or try_catch try/catch region, at any depth, validateControlFlow refuses (no single entry or exit, a cycle inside the region, a region its own schema refuses, a parallel with fewer than two branches) is now an error finding rule: 'flow-region-malformed', folded into localIssues, so the verdict is the gate's existing 422 INVALID_METADATA. Right, and it is the one accept-set change in the diff.
  2. The judge is the engine's, and per-node equals whole-flow. canonicalizeFlow (flow-definitions.ts) runs validateControlFlow(parsed) on the full FlowSchema.parse of the D2-converted body, with no options. validateControlFlow takes { nodes } only: collectFlowGraphs gets no edges (so the root graph's edges are [] and never read), regionSlotsOf reads one node's own config, analyzeRegion reads one region's own nodes/edges, and the depth counter starts at 0 for the root in both callers. There is no cross-node state, so the whole-flow call throws if and only if at least one per-top-node call validateControlFlow({ nodes: [top] }) throws. The set the gate refuses equals the set the engine refuses; the set admitted is the same. I looked for a false refusal and found none: a legacy flat loop with no config.body is skipped by regionSlotsOf (value == null → continue) in both; a region schema-refused is left RAW by parseFlowNodeRegions and named by validateControlFlow in both. Right.
  3. Message parity. Within one top-level container the engine's order is: that node's own slots during the root-graph pass, then its nested graphs pre-order. The single-node call walks the same subtree in the same order, so the sentence is the one the engine throws for that container. Across containers the engine throws once (first fault in root-graph order, nested faults after every root-level one); the gate reports one finding per top-level container at fault, a superset of the engine's one sentence, each in the engine's own words. The PR's acceptance note states exactly this. Right.
  4. Parse parity, door by door. At the live save door the gate judges flowGateVerdictBody ?? gatedItem, the body the schema gate already parsed with the same FlowSchema (protocol.ts, the schema.safeParse(flowGateVerdictBody ?? request.item) line), D2-converted through applyConversionsToFlow when the canonicalizer threw, which it does for a malformed region since canonicalizeFlow itself calls validateControlFlow. So the rule's FlowSchema.safeParse succeeds exactly when the schema gate's does, and a body the schema refuses gets no region finding (if (!parsed.success) return []): no double report with schema findings. At the per-item promotion and the draft read the body is the stored draft row as the precedent reviewed it. Right.
  5. Doors reached: the same four as fix(metadata)!: refuse a flow with an undeclared cycle at publish, as the engine refuses it at registration #29226 (live PUT in non-draft mode, per-item POST …/publish before repo.promoteDraft, publish-drafts through that promotion with outcome: 'refused', and duplicatePackage through the save door). Right; the dogfood file pins the first three with expectNoActiveRow after each.
  6. Draft read: serveDraft → draftPublishVerdict → itemRelativeIssuePath re-spells flows[0].nodes[1] to nodes.1 and flows[0].nodes[1].config.try.nodes[0] to nodes.1.config.try.nodes.0; the draft-verdict test and the dogfood case pin both spellings. Right.
  7. Draft save unchanged (D1): evaluateRuntimeAuthoringGate returns { error: null, advisories: hierarchyScopeGaps } for state !== 'active' at line 1395, above the new call at line 1479. A work-in-progress malformed region still saves; the pure-gate test pins it. Right.
  8. migrate-stored carve-out unchanged: the wrapper's early clean for that source precedes the gate, as in fix(metadata)!: refuse a flow with an undeclared cycle at publish, as the engine refuses it at registration #29226; a stored flow is re-registered by the engine exactly as before (still refused). The changeset's Unchanged and remedy paragraphs say this correctly. Right.
  9. Location is sound. The anchor starts on the top-level container and descends only to a walked descendant whose own refusal, asked alone, is the whole message or its →-separated tail. A tail names a container by type 'id' slot, and FlowSchema refuses a duplicate node id anywhere in the flow (flow.zod.ts, "every node id in a flow must be unique"), so at most one container answers a given tail, the matching containers form one nested chain, and the pre-order last match is the innermost. The parallel '…': a parallel block needs at least 2 branches sentence carries no scope prefix, which is why the message === inner branch exists; the draft-verdict test pins that case nested in a try region. Locating never changes the verdict or the message. Right. One cap mismatch: walkNodes stops at depth 16 while the spec walks to MAX_REGION_DEPTH 32, so a fault 17 or more regions deep would anchor on the deepest walked ancestor of the chain (still on the chain, verdict and sentence unchanged). Should-fix, not must-fix.
  10. No overlap with flow-undeclared-cycle. findFlowCycleGaps reads the top-level nodes/edges only; validateControlFlow never reads the top-level edges and judges region graphs only. A flow with both faults gets two findings under two rules, one per fault, which is not a double report; the engine would stop at the cycle (detectCycles runs first), the gate names both. Right.
  11. Public surface. FLOW_REGION_MALFORMED and findFlowRegionGaps are module exports of runtime-authoring-gate.ts; src/index.ts re-exports only SDUI_MANIFEST_SERVICE from it and the package exports map has "." alone, so neither is package-public (as ①.10 of the precedent). The one new wire value is the rule id flow-region-malformed in issues[].rule / _diagnostics.errors[].code, plus its presence in rulesRun for every flow (between flow-undeclared-cycle and platform-schedule-create-record-org-missing; the only pins on that array are toContain). The imports FlowSchema, validateControlFlow, FlowNodeParsed come from @objectstack/spec/automation, which the file already imported FLOW_REGION_SLOTS_BY_TYPE from: no new package edge. Right.
  12. Main has moved 19 commits since merge-base 94d3e2543; none touches runtime-authoring-gate.ts, control-flow.zod.ts or flow-definitions.ts (git log merge-base..origin/main restricted to those three paths is empty). The PR's "none of them touch the gate file" is right.

② Semver level

  • Changeset .changeset/29233-flow-region-malformed-publish.md: '@objectstack/metadata-protocol': minor, a BREAKING banner with Before / Now / One judge / Unchanged bullets and a remedy paragraph, line-initial Clause-②: no (narrowing) and Metadata-protocol: narrowed, and exactly one adr-0087: not-required (no-migration-prescription) marker. The PR body carries the same two declaration lines line-initial; the title is fix(metadata-protocol)!:.
  • The kind is narrowed by AGENTS.md's own words (a value refused at a door that accepted it), the level follows the kind, and it is the same kind, level and banner shape that fix(metadata)!: refuse a flow with an undeclared cycle at publish, as the engine refuses it at registration #29226 landed with on the same gate. Check Changeset and Lint · PM dispatch-gates (which carries check-adr-0087-registration and check-changeset-no-major) are success on this head. Right; declarations, level and gates agree.
  • Only one package moves (metadata-protocol); no spec, metadata-core or service-automation entry is owed, since none of them changes.

③ Boundary flags

④ Design judgment

  • packages/metadata-protocol/src/runtime-authoring-gate.ts — holds against the card's scenario. Pure (reads the body only; FlowSchema.safeParse, never parse, so a schema-refused body is the schema's and the rule adds nothing). The judge is imported, not restated (①.2); the per-node asking is equivalent to the engine's whole-flow call (①.2, ①.3); the location walk is on the parsed flow, whose list order the parse preserves, and it never judges (①.9). Folded into localIssues behind flowCycleGaps and ahead of scheduleOrgGaps, which changes only ordering among gate-local errors, which nothing pins; disclosed in rulesRun. I tried to break it with: the card's self-closing loop body; a one-branch parallel, top-level and nested in a try region; a try region that closes on itself; a region whose nodes is not a list; two malformed top-level containers; a well-formed nested loop inside a try region; a legacy loop with no body; a top-level undeclared cycle plus a malformed region; a draft; the D2-fallback body at the live door; duplicate node ids across regions (refused by the schema before the rule runs). In every case the gate's verdict and sentence are the engine's; the only degradation found is the depth-17+ anchor (①.9), which is location only.
  • The narrowing itself against the live doors (the face's own question): at the live save door the canonicalizer throws on a malformed region, the gate judges the D2-converted raw body and throws before step 1 writes; the per-item promotion judges the stored draft body before promoteDraft; publish-drafts goes through that promotion and answers outcome: 'refused', publishedCount: 0; the draft read says valid: false with the located code and the engine's sentence. A well-formed loop, parallel and try_catch region, nested too, is unchanged at every door and registers (pure-gate control, draft-verdict control with publishedCount: 3, dogfood control that waits for registration.state: registered). Stored flows: the active read is unchanged, registration is unchanged, migrate-stored is not gated. The narrowing makes the engine's own refusal real one door earlier and takes no registrable capability away.
  • Test files (read for the parity claim, not reviewed): the pure-gate suite derives every expected message from validateControlFlow(FlowSchema.parse(flow)) on the whole flow, so the per-node gate is pinned against the engine's own call, not a restatement; the draft-verdict table gains the card's loop as a CASES row (draft saves, reads valid:false, per-item 422, package publish rolls back), a three-flow control, and the nested-location pin; the dogfood file drives the card's table, the per-item and live doors, and the control over REST on the showcase composition, with per-test budgets and no assertion weakened.
  • No must-fix found. Should-fix: the docblock's "for the reason given there" (③), and the walkNodes depth cap (①.9).

Implemented-by: claude/project-thread-qv2l74
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt

VERDICT: PASS

…istry rule

Seat ruling: one judge, one name. The gate-local flow-region-malformed
rule is dropped and validateFlowRegionShape (flow-region-shape-invalid)
now runs on the runtime publish gate for flow writes
(CLI_AND_RUNTIME, runtimeTypes flow). The door pins and the dogfood file
are re-pointed to the registry rule id; two lint test files get
schema-valid flow fixtures, since the rule refuses a flow the schema
refuses.

Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m and removed size/l labels Nov 2, 2026
@objectstack-fleet objectstack-fleet Bot changed the title fix(metadata-protocol)!: refuse a flow whose loop, parallel or try_catch region is malformed at publish fix(lint)!: refuse a flow whose loop, parallel or try_catch region is malformed at publish Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Delta review on the earlier record 6584290147 (PASS on head 813305213, the gate-local flow-region-malformed version). This record re-judges the rework head after the seat's "one judge, one spelling" ruling (claim amendments 6584332282, 6585054494, 6585210573) against the dev's delta report 6586851931 and the r2 PR body now applied. Everything the earlier record judged on packages/spec/src/automation/control-flow.zod.ts, flow.zod.ts and packages/services/service-automation/src/flow-definitions.ts (the judge and its call order) is unchanged on main and is not re-read here; everything about the gate-local rule is superseded, since that rule is gone.

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2cb755efdb4bfd792eb46b6ca88f155a55fb63ff
Local-runs: none
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt
Implemented-by: session_01ADHkmSfgX3fNF6rAXudbtz
Scope: packages/lint/src/authoring-rules.ts (the validateFlowRegionShape row), packages/lint/src/runtime-gate.test.ts, packages/lint/src/validate-flow-trigger-readiness.test.ts, packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts, packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts, packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts + packages/lint/src/validate-flow-region-shape.ts (validateFlowRegionShape, FLOW_REGION_SHAPE_INVALID), packages/lint/src/runtime-gate.ts (runtimeAuthoringRulesFor, runRuntimeAuthoringRules, buildRuntimeWriteSnapshotSet, nameKeyFindingPath), packages/lint/src/authoring-rule-wiring.test.ts (the surface guard), packages/lint/src/rule-explanations.ts (the flow-region-shape-invalid entry), packages/lint/src/index.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts (evaluateRuntimeAuthoringGate, findFlowCycleGaps, the rulesRun disclosure; equal to main), packages/metadata-protocol/src/protocol-authoring-gate.ts (judgeRuntimeAuthoringRules, draftPublishVerdict), packages/metadata-protocol/src/protocol.ts (saveMetaItem's flowGateVerdictBody, schema gate and assertRuntimeAuthoringRules call; promoteDraftForPublish's call on draftForGate.body), packages/metadata-protocol/package.json (the @objectstack/lint edge), .changeset/config.json, .changeset/29233-flow-region-shape-publish.md (read for ②), the three claim amendments and the dev delta report named above.

Face: narrowing (Clause-②: no (narrowing) and Metadata-protocol: narrowed line-initial on the PR body and the changeset; no packages/spec/src/** path, no governed path). Merge-base 2789ba7d; net diff 7 files, 479 insertions, 12 deletions (491 changed lines, far under 3,000). The PR file list equals the claim's declared surface plus the two amendments' increments exactly: the original claim named runtime-authoring-gate.ts, tests, a dogfood file and a changeset; amendment 6584332282 added the authoring-rules.ts row; amendment 6585054494 added the two lint test files. No other path changed. packages/metadata-protocol/src/runtime-authoring-gate.ts is byte-equal to origin/main (empty git diff origin/main 2cb755ef on that path), the earlier changeset 29233-flow-region-malformed-publish.md is gone, and main has moved 10 commits since the merge-base, none touching the row, the rule file, the lint dispatcher, the gate, the authoring-gate host or the three judge files (the one same-package commit, #29548, is a protocol.ts read-path refactor this PR does not touch).

① Derived judgments

  1. The accept-set change is one registry row. packages/lint/src/authoring-rules.ts moves validateFlowRegionShape from surfaces: CLI_ONLY + surfaceReason to surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow'], and nothing else in the file moves (+6/-5, that row only, as amendment 6584332282 declared). runtimeAuthoringRulesFor('flow') (runtime-gate.ts) filters on exactly those two fields, so every flow write judged active by evaluateRuntimeAuthoringGate now runs the rule and discloses it in rulesRun under the registry name validateFlowRegionShape (the pure-gate test pins toContain; the lint pin at runtime-gate.test.ts line 105 is derived from the registry, not literal). Right, and it is the only accept-set change in the diff.
  2. Judge parity with the engine, with no second call. validateFlowRegionShape (unchanged in this PR, feat(lint)!: os lint refuses a flow region the engine refuses to register (flow-region-shape-invalid) #29495's file) runs FlowSchema.safeParse then validateControlFlow(result.data), the two calls canonicalizeFlow makes, stops at the flow's first refusal as the engine's single throw does, and restates no rule. runtime-authoring-gate.ts holds no gate-local region rule any more, so there is one judge and one spelling, which is the ruling. The earlier record's ①.2 (per-node equals whole-flow) is moot: the rule asks the whole flow, as the engine does. Right.
  3. No skip arm and no runtime arm. The rule's only early exits are stack.flows not an array (no flows to judge) and a non-record entry (every registry reader skips it, documented). A body FlowSchema refuses is an error finding "could not be judged for its region shape", never a skip, and the rule reads no surface or runtimeWriteType context, so the CLI and the runtime run the same code. The wiring guard (authoring-rule-wiring.test.ts, silent/undeclared assertions) accepts the row because runtimeTypes is set. Right, and it is what amendment 6585210573 forbade ("a runtime arm that skips schema-refused bodies") not being added.
  4. Doors reached. The same three publish doors as before plus the draft read: the live PUT in non-draft mode (saveMetaItem → assertRuntimeAuthoringRules with state: 'active'), the per-item POST …/publish and publish-drafts through promoteDraftForPublish (assertRuntimeAuthoringRules on draftForGate.body before repo.promoteDraft), and the draft read's draftPublishVerdict → judgeRuntimeAuthoringRules with state: 'active'. The dogfood file pins all three doors with expectNoActiveRow after each refusal and valid: false on the draft read. duplicatePackage goes through the save door and is reached too, as before. Right.
  5. Draft save unchanged (D1). judgeRuntimeAuthoringRules returns clean/advisories only for state !== 'active' before any rule runs, and evaluateRuntimeAuthoringGate returns at line 1295 for the same condition; the pure-gate test pins a malformed draft saving. The migrate-stored carve-out precedes the rules in both branches, so stored rows are re-registered as before. Right.
  6. Body judged, door by door, and the ruling-A seam. At the live door the gate receives flowGateVerdictBody ?? gatedItem, the body the schema gate parsed two hundred lines earlier with the same FlowSchema and threw 422 on if it failed; so at that door the rule's schema-refused arm is unreachable and there is no double report beside schema findings. At the two promotion doors and the draft read the body is the RAW stored draft row, with no schema parse in front of the rules (none in promoteDraftForPublish or draftPublishVerdict); a stored body FlowSchema refuses raw gets this rule's "could not be judged" finding beside whatever other registry rules say (expression-invalid on the measured legacy D2 spelling). That is the seam amendment 6585210573 ruled on (A: land as declared; the root fix, judging the converted body at the publish doors as the save door does, is server(metadata): the publish doors gate the raw stored flow draft, while the live save gates its D2-converted body (#21982), so a legacy spelling the engine registers is refused at publish #29557, open, pm:queue), and the dev's measurement is in the PR body: every raw-refused body except a legacy D2 spelling is refused by registerFlow too; the one D2 spelling measured is already refused by expression-invalid on main, so no new refusal there; whether some other legacy spelling is refused by this rule alone is stated as NOT MEASURED. A new draft stores the canonicalized body when the automation service resolves (request.item = result.storable), so the raw-legacy stored draft is reachable from a host without the canonicalizer or a seeded row, as the measurement did. Right as ruled; the record names it so the landing reads it.
  7. No double report beside flow-undeclared-cycle. findFlowCycleGaps (gate-local, unchanged) judges the top-level nodes/edges only; validateControlFlow judges region graphs only and the rule's docblock says so ("Not judged here: a cycle in the flow's TOP-LEVEL graph"). A flow with both faults gets one finding per fault under two rules. Right.
  8. The differential does not cancel the finding on an update. buildRuntimeWriteSnapshotSet puts a flow write as the sole member of flows in the candidate and holds no flows in the baseline (flows is not a context collection, so the finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118 stored-universe pass does not apply); a flows[0] finding is always new. An update of an active flow into a malformed one is refused like a create. Right; the dogfood live PUT is a create, the pure-gate cases cover the shape, and the mechanism is the registry's own, exercised by every other runtimeTypes: ['flow'] rule.
  9. Location and message, as accepted. The finding anchors the flow (path: 'flows[0]' on the gate's issues; '' item-relative on the draft read, pinned in the draft-verdict test and the dogfood), the message carries the prefix "Flow 'NAME' would be refused at registration: " before the engine's sentence, one finding per flow. All three are the differences amendment 6585054494 accepted; finer location is lint: flow-region-shape-invalid anchors the whole flow; point the finding at the region container at fault #29558, open. The pure-gate suite derives every expected sentence from validateControlFlow(FlowSchema.parse(flow)), so the parity pin is against the engine's call, not a restatement. Right.
  10. Public surface and rule id. No export changes: validateFlowRegionShape and FLOW_REGION_SHAPE_INVALID were already exported from @objectstack/lint by feat(lint)!: os lint refuses a flow region the engine refuses to register (flow-region-shape-invalid) #29495, runtime-authoring-gate.ts adds nothing, and rule-explanations.ts already carries the entry. The one wire-visible change is that flow-region-shape-invalid now appears in issues[].rule / _diagnostics.errors[].code at the gate and validateFlowRegionShape in rulesRun for every flow; the gate-local id flow-region-malformed never shipped (the earlier head was not merged), so no spelling is retired. Right.
  11. Forced test edits. runtime-gate.test.ts (brokenApprovalFlow/cleanApprovalFlow: a type, node labels, an edge list, the top-level trigger removed) and validate-flow-trigger-readiness.test.ts (apiFlow, startSecond: flow and node labels). Each case still isolates the one thing it pins; I read every hunk and no expect moved. The fixtures were flows FlowSchema refuses, which the engine never registers and which the rule now reports on the gate, so making them schema-valid is the only honest repair. Right, within amendment 6585054494.

② Semver level

  • Changeset .changeset/29233-flow-region-shape-publish.md: '@objectstack/lint': minor, a BREAKING banner with Before / Now / One judge / Unchanged bullets and a remedy paragraph, line-initial Clause-②: no (narrowing), Metadata-protocol: narrowed and Skills-docs: none — REASON, and exactly one adr-0087: not-required (no-migration-prescription) marker. Only @objectstack/lint moves in the diff, and it is the one package the changeset bumps; @objectstack/metadata-protocol's source is unchanged and reaches the new behaviour through its workspace:* edge on lint, which the fixed group in .changeset/config.json versions together with lint anyway. The kind narrowed takes at least minor on the moved package: minor on lint is the level the rule table gives. Check Changeset and Lint · PM dispatch-gates (which carries check-adr-0087-registration and check-changeset-no-major) are success on this head. Right.
  • PR title fix(lint)!: names the moved package and carries the bang; the PR body carries both declaration lines line-initial. Right.
  • Two prose mismatches, neither changing the level: (a) the changeset's headline reads fix(metadata)!: while the bumped package is lint and the title says fix(lint)!:; that word ships into lint's CHANGELOG.md. (b) The PR body carries no line-initial Skills-docs: line, which the claim template owes for a kind other than none; the changeset has it. Both should-fix, below.

③ Boundary flags

  • Dev open_questions: none. The two declared deviations are answered: the PR title and body are now the r2 text (read back above, both declaration lines present), and the changeset rename plus package switch is exactly what ② judges.
  • Claim amendments honoured. Serial behind feat(lint)!: os lint refuses a flow region the engine refuses to register (flow-region-shape-invalid) #29495: that PR is merged (7f368f56, carried by the branch's merge f99a058a), so the row exists on this head's main. The owed measurement from 6585054494 is in the PR body and ①.6 reads it. Ruling A's not-taken options (a runtime arm, keeping the gate-local rule) are not in the tree.
  • Check-runs on head 2cb755ef at the time of this record: 68 runs, all completed; 57 success, 11 skipped, 0 failed or cancelled. The ten required contexts: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI are success; Lint UI is skipped by its own path filter (needs.filter.outputs.ui_tree, ci.yml), as expected for a diff with no ui/** path. The other ten skips are opt-in or full-battery-only jobs (Dogfood Verify CLI, Build Docs, Temporal Conformance, Packed-tarball smoke, 18.x upgrade harness, UI Published Artifacts, the schedule gate, the dogfood shard matrix, and the duplicate Check PR Size/Auto Label rows). The all-green precondition holds on the completed conclusions; the seat reads it again before queueing.
  • Governed surface: none of the seven paths; size 491 changed lines. PR author is objectstack-fleet[bot]; no relay author to name.
  • Should-fix (prose, no head move required for the first): (1) add the line-initial Skills-docs: none — … line to the PR body, mirroring the changeset, since the kind is narrowed. (2) The changeset headline's scope word fix(metadata)!: should read fix(lint)!: to match the bumped package and the title; fold into any later push, or accept as a changelog cosmetic.
  • Should-fix, carried off this PR: the rule's docblock (validate-flow-region-shape.ts) and its rule-explanations.ts entry name only the three CLI commands as readers; the runtime publish gate is now a third reader that hands the raw stored body at the promotion doors and the D2-converted body at the live door, which is why the "could not be judged" arm is reachable at a publish door and not at the live one. One sentence there, pointing at server(metadata): the publish doors gate the raw stored flow draft, while the live save gates its D2-converted body (#21982), so a legacy spelling the engine registers is refused at publish #29557, belongs to that card's fix (the file is lint: os lint stays blind to a flow region the engine refuses to register; validate and build refuse it since #28893 through a CLI-only gate #29049's, outside this PR's declared surface), not to this head.
  • Unnamed consequence, as before: duplicatePackage of a package holding a stored flow with a malformed region now refuses the duplicate, by the gate's existing intent ("a copy of a broken flow is a new broken flow"). No change asked.

④ Design judgment

  • packages/lint/src/authoring-rules.ts, the row — holds against the card's scenario and the ruling. The row is the registry's own declaration form for "this rule runs at the publish gate for these types" (surfaces + runtimeTypes, [runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D2 option (c) as data), the same shape the other runtimeTypes: ['flow'] rows use; the surfaceReason it retires said the runtime question was the server lane's to decide, and this row is that decision. The comment names the card and the gate and says why no gate-local copy exists. Nothing else in the registry moves.
  • The narrowing against the live doors (the face's own question): at the live save door the schema gate parses the D2-converted body, the rule judges the same body, and a malformed region is refused 422 INVALID_METADATA before step 1 writes, in the engine's sentence; at the per-item promotion the stored draft is refused before promoteDraft; publish-drafts answers outcome: 'refused', publishedCount: 0; the draft read answers valid: false with the rule's code at the item root and the engine's sentence. A well-formed loop, parallel and try_catch region, nested too, is unchanged at every door and registers (pure-gate control, draft-verdict control with publishedCount: 3, dogfood control waiting on registration.state: registered). Stored flows: active reads, registration and migrate-stored are unchanged. I tried to break it with: the card's self-closing loop body; a one-branch parallel, top-level and nested in a try region; a try region that closes on itself; a region whose nodes is not a list; two malformed containers; a well-formed nested loop inside a try region; a draft; a top-level undeclared cycle plus a malformed region; an update of an active flow into a malformed one; a stored legacy D2 draft at the publish doors. In every case the verdict is the engine's or the ruled difference (①.6); the only behaviour the registry route adds over the gate-local one is the publish-door finding on a raw schema-refused stored draft, which the seat ruled on and carded. The narrowing makes the engine's own refusal real one door earlier, through the one judge the CLI already uses, and takes no registrable capability away.
  • Test files (read for the parity and door claims, not reviewed): the pure-gate suite derives every expected sentence from the engine's own call; the draft-verdict table gains the card's loop as a CASES row (draft saves, reads valid:false, per-item 422, package publish rolls back), a three-flow control, and the item-root location pin; the dogfood file drives the card's table, the per-item and live doors, and the control over REST on the showcase composition, with per-test budgets and no assertion weakened. The two forced lint edits are fixture repairs only (①.11).
  • No must-fix found. Should-fix: the PR body's Skills-docs: line and the changeset headline scope (②); the rule docblock's third reader, carried by server(metadata): the publish doors gate the raw stored flow draft, while the live save gates its D2-converted body (#21982), so a legacy spelling the engine registers is refused at publish #29557 (③).

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

席位核对

ACCEPT · domain:server seat 2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T20:02Z · head 2cb755ef · claimed by seat 2 on #29233 (claim 6580640709, amendments 6584332282, 6585054494, 6585210573)

  1. Shape: a PR against main with Fixes #29233. Clause-②: no (narrowing), Metadata-protocol: narrowed and Skills-docs: are line-initial on the body. The changeset moves @objectstack/lint minor with a BREAKING banner, which is right for a narrowing. check-governed-merges --pr 29526: NOT governed (0 of 7 paths). No packages/spec, no ui/**.
  2. One judge, one spelling: the gate-local flow-region-malformed rule is gone, and runtime-authoring-gate.ts equals main. The region check is feat(lint)!: os lint refuses a flow region the engine refuses to register (flow-region-shape-invalid) #29495's registry row flow-region-shape-invalid (CLI_AND_RUNTIME, runtimeTypes: ['flow']). There is no second call, no skip arm and no runtime arm. The earlier name question is settled: there is one rule id, and it is feat(lint)!: os lint refuses a flow region the engine refuses to register (flow-region-shape-invalid) #29495's.
  3. Contract review: the delta review is PASS (6587135947) on this head, building on the earlier PASS 6584290147. The diff is 7 files, all inside the declared surface plus the amended test files. The forced test edits repair fixtures only, and no assertion moved.
  4. Ruling A difference: the publish doors judge the raw stored draft. The measured legacy spelling (get_record config.filters) is already refused on main by expression-invalid, so this PR adds no new refusal there. Whether another legacy spelling is refused by this rule alone was not measured. The root seam is server(metadata): the publish doors gate the raw stored flow draft, while the live save gates its D2-converted body (#21982), so a legacy spelling the engine registers is refused at publish #29557 and the finer location is lint: flow-region-shape-invalid anchors the whole flow; point the finding at the region container at fault #29558.
  5. Should-fix handling:
  6. Evidence:
    • lint: 7885/7885.
    • metadata-protocol: 22240 passed, typecheck 0.
    • 63 of 63 gates exit 0.
    • Full dogfood: 3280 passed, exit 0.
    • The ablation (row back to CLI_ONLY) turns 8 pins and 2 dogfood cases red, and a restore leaves the tree clean.
    • CI on this head: 68 runs, 57 success, 11 skipped, 0 failed.
  7. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 20:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 20:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 0814b89 Nov 2, 2026
73 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qv2l74 branch November 2, 2026 20:27
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