Skip to content

feat(spec)!: FlowNodeSchema parses its own ADR-0031 regions — the post-parse pass retires (#4415) - #6333

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-4415-flownode-regions-parse
Aug 7, 2026
Merged

feat(spec)!: FlowNodeSchema parses its own ADR-0031 regions — the post-parse pass retires (#4415)#6333
hotlong merged 3 commits into
mainfrom
claude/issue-4415-flownode-regions-parse

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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:

第一步派测量任务:量清 FlowNodeSchema.transform()(ZodObject→ZodPipe)后对 z.toJSONSchema、form 生成、lazy-schema seen 表的实际影响(ADR-0089 D3a 有前科,.strict().transform pipe 曾打崩 toJSONSchema)。

注意:近期有 region-slots changeset 活动,测量以最新 main 为准;与 #4336(node config 上类型)大概率是同一机制两次应用,执行时互相知会。

2026-08-07 (maintainer, in chat):

Premise to verify BEFORE any implementation: the ZodPipe shape produced by .transform() on the self-recursive FlowNodeSchema can be digested by the existing generators — the toJSONSchema walker and the lazy-schema path, i.e. exactly the shape that crashed before (ADR-0089 D3a). Verify by measurement (a minimal spike against the real generators), not by reading.

Premise verification — the issue body's claim still stands on origin/main

Checked before implementing, as the lead-not-spec rule requires:

So premise_still_valid: true.

Measurement — all three named targets, against the real generators

Spike: attach the real region-parsing .transform() to FlowNodeSchema, then run the generators. Not a reading exercise.

Target 1 — z.toJSONSchema walker (scripts/build-schemas.ts, under OS_EAGER_SCHEMAS=1)

$ pnpm --filter @objectstack/spec check:authorable-surface
  ✓ automation/FlowNode.json (input shape)
  Generated: 1622 (132 as input shape)
✅ Successfully generated 1622 schemas.          EXIT=0

No crash, and the authorable key set is unchanged — git status showed zero delta on the tracked authorable-surface/ and json-schema.manifest/ artifacts. It works because pipeAuthorableSide (scripts/lib/zod-graph.ts, #5317) already resolves an a.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 (the METADATA_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.ts after being pointed at the pipe's IN side.

Target 3 — lazy-schema seen table (the ADR-0089 D3a crash site)

Exercised in the non-eager path, where the lazySchema Proxy and its _zod facade are actually live:

$ pnpm --filter @objectstack/spec gen:openapi
✅ Generated OpenAPI spec: json-schema/openapi.json      EXIT=0

Plus a direct probe of the reuse shape that broke FormFieldSchema / PageComponentSchema — one Proxy referenced three times in one conversion root:

[0] FlowNodeSchema def.type = pipe   .in = object   .out = transform
[1] toJSONSchema io=input  FlowNodeSchema / FlowSchema / FlowRegionSchema: OK
[1] toJSONSchema io=output FlowNodeSchema / FlowSchema / FlowRegionSchema: OK
[4] reused-Proxy seen-table path: OK ($defs=1)

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:

  1. The mutual recursion is a real ES module cycle. FlowNodeSchema needs FlowRegionSchema; FlowRegionSchema holds z.array(FlowNodeSchema). Under OS_EAGER_SCHEMAS=1 — which gen:schema sets, bypassing the Proxy — the region schemas must back-reference through z.lazy(() => FlowNodeSchema), and the object half must be a hoisted function declaration. Without either, module evaluation dies before any test runs:

    ReferenceError: Cannot access 'FlowNodeSchema' before initialization
        at control-flow.zod.ts:142
    
  2. FlowNodeSchema no longer has .shape. One consumer repo-wide read it (io-node-form-zod-ledger.test.ts); it now reads FlowNodeSchema.def.in.shape, the same authorable side the generators use. objectui was checked too — its flow-designer code uses only .parse / .safeParse, so it is unaffected.

What changed

  • FlowNodeSchema carries 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's nodes are z.array(FlowNodeSchema), so Zod re-enters the transform on the way down.
  • normalizeControlFlowRegions retires, with its call site. parseFlowNodeRegions replaces it as the exported unit.
  • The recursion depth ceiling moves from an explicit depth argument to a re-entrancy counter, because the descent now happens inside Zod, which has nowhere to carry one. MAX_REGION_DEPTH is unchanged, and a self-referential hand-built region is pinned to terminate at FlowSchema.parse — a hazard that is newly reachable through parse itself.
  • A value that does not parse is still returned untouched: rejecting a malformed region stays validateControlFlow's job, so the transform never changes which flows parse.

Reverse verification — direction predicted before running: RED both times

reverted predicted observed
remove .transform(parseFlowNodeRegions) RED on the new pins 6 failed | 10 passed — exactly the six asserting the new guarantee; the ten collectFlowGraphs / copy-on-write / untouched-region / legacy-loop cases stayed green, as they should
remove z.lazy() from the region back-reference RED at import, eager mode ReferenceError: Cannot access 'FlowNodeSchema' before initialization at control-flow.zod.ts:142

The first is the ordinary direction. Worth naming the one assertion that could not simply be re-spelled: region-normalization.test.ts opened by pinning the defect

expect((parsed.nodes[1].config as any).body.edges[0].condition).toBe(CONDITION);

i.e. "after FlowSchema.parse the 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 at FlowSchema.parse alone.

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 configSchema for decision, 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 test333 files / 8499 tests passed
  • pnpm --filter @objectstack/service-automation test67 files / 797 passed
  • pnpm --filter @objectstack/lint test61 files / 1504 passed
  • pnpm --filter @objectstack/spec exec tsc --noEmit — clean; typecheck (incl. check:test-typecheck) clean
  • pnpm lint — clean
  • Every check:* step enumerated from .github/workflows/lint.yml, run one at a time — 48/48 ok, including check: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-bytes
  • Merged origin/main (23 commits), committed the merge, then rebuilt and regenerated — never gen:schema in MERGE state

Generated-artifact delta is exactly three lines: the api-surface export swap, and two rows in content/docs/references/automation/flow.mdx where 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-existing strict-object.tsfield.zod.ts TDZ (Cannot access 'DECLARATIONS' before initialization). Measured on pristine origin/main copies, so it is not this branch's doing; dormant because every real entry point goes through a barrel. It is why flow-region-cycle.test.ts pins the barrel and flow.zod-first orders but deliberately not control-flow.zod-first.


Generated by Claude Code

claude added 3 commits August 7, 2026 13:17
`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
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
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 2:01pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/spec.

112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@hotlong
hotlong marked this pull request as ready for review August 7, 2026 14:35
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 4d552af Aug 7, 2026
25 checks passed
@hotlong
hotlong deleted the claude/issue-4415-flownode-regions-parse branch August 7, 2026 14:51
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
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Design: should FlowNodeSchema parse its ADR-0031 regions, instead of a post-parse pass callers must remember to run? ✨ Set up Copilot instructions

2 participants