feat(spec)!: FlowNodeSchema parses its own ADR-0031 regions — the post-parse pass retires (#4415) - #6333
Merged
Merged
Conversation
`FlowSchema.parse` could not reach a region — regions live inside `FlowNodeSchema.config`, a deliberately open `z.record` (ADR-0018) — so #4381 closed the gap with a post-parse pass (`normalizeControlFlowRegions`) every caller had to remember to run. That unwritten rule is the condition the #4347 defect family grows in: a new consumer takes a `FlowParsed` and uses it, half-parsed and looking finished. `FlowNodeSchema` now carries a `.transform()` that parses each declared region slot through the schema that slot's value is. Nesting needs no manual recursion: a region's `nodes` are `z.array(FlowNodeSchema)`, so Zod re-enters the transform on the way down. The post-parse pass and its `registerFlow` call site retire. Premise measured first, per the maintainer ruling — the ZodPipe is digested by all three named generators (toJSONSchema walker, form generation, the lazy-schema seen-table path). Two mechanical prerequisites the measurement surfaced: the region schemas back-reference through `z.lazy()`, and the object half is a hoisted function declaration, both load-bearing under `OS_EAGER_SCHEMAS=1`; pinned by `flow-region-cycle.test.ts`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BickTBKm2JYSNnrtPT8ysa
…wnode-regions-parse
Net delta vs origin/main is exactly the two input-shape lines #4415 intends (`inputSchema[].required`, `boundaryConfig.interrupting` render optional now that FlowNode is read from the pipe's authorable IN side). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BickTBKm2JYSNnrtPT8ysa
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
hotlong
marked this pull request as ready for review
August 7, 2026 14:35
os-zhuang
pushed a commit
that referenced
this pull request
Aug 7, 2026
main 上有 8 个参考页在本分支开出后被重生成(#6243、#6280、#6281、#6333 等), 其中 `ui/bulk-action.mdx` 与 `automation/state-machine.mdx` 与本分支相交。 两棵独立重生成的树会零冲突合并却落地陈旧组合,故按 os-regen 钩子的要求从合并后的 树重新 `gen:schema && gen:docs`,并在合并结果上重跑全部验证: - 参考语料 216 页 / 8548 个单元格,check:docs 232 个生成文件 in sync - MDX 编译 216/216 - @objectstack/spec: 337 个测试文件 / 8614 个测试通过,typecheck 干净 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4415
Measurement-first, per the maintainer ruling. The premise held, so this lands direction 1.
The ruling this implements
2026-08-03 (PM 代决) / 2026-08-06 (维护者批复), quoted verbatim:
2026-08-07 (maintainer, in chat):
Premise verification — the issue body's claim still stands on
origin/mainChecked before implementing, as the lead-not-spec rule requires:
normalizeControlFlowRegionsstill existed inpackages/spec/src/automation/control-flow.zod.ts, still exported, still called exactly once —packages/services/service-automation/src/engine.ts:2098, immediately afterFlowSchema.parse+validateControlFlow. The three-line sequence in the issue body was byte-accurate.git log origin/main -20 -- 'packages/spec/src/**flow*') is two commits, neither touching the pass: ADR-0122XParsedrenaming (feat(spec): ADR-0122 第一期 —— 解析态统一命名XParsed(纯增量)+ 防回流门 #6072) and the tombstone key-matching fix (fix(spec): 退休登记按确切 key 判定 —— 无关簇的同名叶子不再替 tombstone 背书 (#4659) #5902).FlowNodeSchemawas still a plain.strict()ZodObject with an openconfig: z.record(…).So
premise_still_valid: true.Measurement — all three named targets, against the real generators
Spike: attach the real region-parsing
.transform()toFlowNodeSchema, then run the generators. Not a reading exercise.Target 1 —
z.toJSONSchemawalker (scripts/build-schemas.ts, underOS_EAGER_SCHEMAS=1)No crash, and the authorable key set is unchanged —
git statusshowed zero delta on the trackedauthorable-surface/andjson-schema.manifest/artifacts. It works becausepipeAuthorableSide(scripts/lib/zod-graph.ts, #5317) already resolves ana.transform(fn)pipe to its IN side; FlowNode is now marked(input shape)for exactly that reason.Target 2 — form generation
src/system/metadata-form-zod-reconciliation.test.ts(theMETADATA_FORM_REGISTRY↔ Zod gate, which walks shapes through the same pipe-aware unwrap) passes inside the full spec suite. Also green:packages/services/service-automation/src/builtin/io-node-form-zod-ledger.test.tsafter being pointed at the pipe's IN side.Target 3 — lazy-schema
seentable (the ADR-0089 D3a crash site)Exercised in the non-eager path, where the
lazySchemaProxy and its_zodfacade are actually live:Plus a direct probe of the reuse shape that broke
FormFieldSchema/PageComponentSchema— one Proxy referenced three times in one conversion root:Verdict: 可解. Nothing crashed; the walker resolves the pipe correctly; the generated artifacts are unchanged apart from two deliberate lines (below).
Two mechanical prerequisites the measurement surfaced
Neither falsifies the premise, but both are load-bearing and both cost a lap:
The mutual recursion is a real ES module cycle.
FlowNodeSchemaneedsFlowRegionSchema;FlowRegionSchemaholdsz.array(FlowNodeSchema). UnderOS_EAGER_SCHEMAS=1— whichgen:schemasets, bypassing the Proxy — the region schemas must back-reference throughz.lazy(() => FlowNodeSchema), and the object half must be a hoistedfunctiondeclaration. Without either, module evaluation dies before any test runs:FlowNodeSchemano longer has.shape. One consumer repo-wide read it (io-node-form-zod-ledger.test.ts); it now readsFlowNodeSchema.def.in.shape, the same authorable side the generators use.objectuiwas checked too — its flow-designer code uses only.parse/.safeParse, so it is unaffected.What changed
FlowNodeSchemacarries a.transform(parseFlowNodeRegions)that parses each declared ADR-0031 region slot (loop.config.body,parallel.config.branches[],try_catch.config.try/.catch) through the schema that slot's value is. Nesting needs no manual recursion — a region'snodesarez.array(FlowNodeSchema), so Zod re-enters the transform on the way down.normalizeControlFlowRegionsretires, with its call site.parseFlowNodeRegionsreplaces it as the exported unit.depthargument to a re-entrancy counter, because the descent now happens inside Zod, which has nowhere to carry one.MAX_REGION_DEPTHis unchanged, and a self-referential hand-built region is pinned to terminate atFlowSchema.parse— a hazard that is newly reachable throughparseitself.validateControlFlow's job, so the transform never changes which flows parse.Reverse verification — direction predicted before running: RED both times
.transform(parseFlowNodeRegions)6 failed | 10 passed— exactly the six asserting the new guarantee; the tencollectFlowGraphs/ copy-on-write / untouched-region / legacy-loop cases stayed green, as they shouldz.lazy()from the region back-referenceReferenceError: Cannot access 'FlowNodeSchema' before initialization at control-flow.zod.ts:142The first is the ordinary direction. Worth naming the one assertion that could not simply be re-spelled:
region-normalization.test.tsopened by pinning the defect —i.e. "after
FlowSchema.parsethe nested condition is still a bare string". That is now false by design, so it was replaced wholesale rather than re-spelled; the surrounding cases were re-targeted atFlowSchema.parsealone.Coordination with #4336
#4336 is closed (2026-08-01, completed) — no in-flight overlap, so there is nothing to knowingly avoid and no heads-up comment to leave on an open issue. Its own close-out comment records that suggested fixes #2/#3 became unnecessary once dialect was decided by source (#4453), and that the remaining structural gap — a per-node-type
configSchemafordecision, which is the "give node config a type" half — moved to #4439, still open. That is the neighbouring application of this same mechanism the 08-03 ruling predicted: this PR types the region sub-structure, #4439 would type the flat config keys. They do not overlap in file surface here.Verification
pnpm --filter @objectstack/spec test— 333 files / 8499 tests passedpnpm --filter @objectstack/service-automation test— 67 files / 797 passedpnpm --filter @objectstack/lint test— 61 files / 1504 passedpnpm --filter @objectstack/spec exec tsc --noEmit— clean;typecheck(incl.check:test-typecheck) cleanpnpm lint— cleancheck:*step enumerated from.github/workflows/lint.yml, run one at a time — 48/48 ok, includingcheck:generated,check:api-surface,check:exported-any,check:authorable-surface,check:docs,check:spec-parsed-alias,check:adr-anchors,check:engine-double-contract,check:nul-bytesorigin/main(23 commits), committed the merge, then rebuilt and regenerated — nevergen:schemain MERGE stateGenerated-artifact delta is exactly three lines: the
api-surfaceexport swap, and two rows incontent/docs/references/automation/flow.mdxwhere FlowNode now renders its input shape, so.default()-carrying keys (inputSchema[].required,boundaryConfig.interrupting) read as optional — which is what an author actually writes.Out of scope, filed separately
#6267 — under
OS_EAGER_SCHEMAS=1, entering a deep spec leaf module as the process entry point dies on a pre-existingstrict-object.ts↔field.zod.tsTDZ (Cannot access 'DECLARATIONS' before initialization). Measured on pristineorigin/maincopies, so it is not this branch's doing; dormant because every real entry point goes through a barrel. It is whyflow-region-cycle.test.tspins the barrel andflow.zod-first orders but deliberately notcontrol-flow.zod-first.Generated by Claude Code