Skip to content

fix(spec): break the eager import cycle that crashed /api and /data under OS_EAGER_SCHEMAS=1 - #19943

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-19930-eager-import-cycle
Sep 25, 2026
Merged

os-zhuang merged 6 commits into
mainfrom
claude/issue-19930-eager-import-cycle

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19930
Clause-②: no

What was wrong

OS_EAGER_SCHEMAS=1 is the documented emergency rollback of the lazy-schema memory optimization (content/docs/deployment/environment-variables.mdx:30). With it set, a process whose first spec import was @objectstack/spec/api or @objectstack/spec/data threw at import. The default lazy path was not affected.

Measured before changing anything (origin/main fdeeea0cc9)

  • Load chain, taken with a Module._load hook on the source entry: src/data/index.ts → data/query.zod.ts → data/filter.zod.ts → shared/strict-object.ts → shared/suggestions.zod.ts → data/field.zod.ts. The throw is ReferenceError: Cannot access 'FilterConditionSchema' before initialization at field.zod.ts:1529 (relatedListFilter), reached through the eager lazySchema at :1000.
  • Whole-package sweep: each of the 1039 non-test source modules taken as the first import of a fresh module graph under the flag: 22 threw, 20 with the FilterConditionSchema signature and 2 (data/authoring-key-lint.ts, kernel/metadata-authoring-lint.ts) with Cannot access 'FIELD_KEY_GUIDANCE' before initialization, through the same suggestions.zod.ts → field.zod.ts edge.
  • FieldType in suggestions.zod.ts was read only inside suggestFieldType (FieldType.options), never while the module loads.
  • scripts/lib/refinement-projection.ts and src/shared/refinement-projection.ts, reported crashing elsewhere, do not crash as a first import under the flag (exit 0 in both probe shapes). No independent second cycle exists.

The fix

The fix is the edge the loop closes on. suggestFieldType and its alias table move out of shared/suggestions.zod.ts into shared/field-type-suggestion.ts, which imports data/field.zod in one direction only. After the move suggestions.zod.ts imports no schema module, and its header says why that must stay true. shared/index.ts, the root barrel and error-map.zod.ts import the function from its new home. Public names are unchanged: suggestFieldType is still exported from @objectstack/spec and @objectstack/spec/shared, same signature, same answers; check:api-surface reports "public API surface + factory signatures unchanged". export-origins/ records the new declaration site. lazy-schema.ts and field.zod.ts are untouched, so both named fences hold.

Ruling A, implemented: an allowlisted skill pointer publishes on existence

Ruling: issue comment 5814528240 (batch #220 item 2, letter A, maintainer 「批 #220 同意」). Cutting the edge changed the textual import closure gen:skill-refs walks; the two objectstack-i18n rows of TRANSITIVE_ALLOWLIST were reached only through that edge, so the generator refused and check:skill-refs was red.

  • packages/spec/scripts/lib/skill-map-guards.ts: a TRANSITIVE_ALLOWLIST row is now a declared pointer. checkTransitiveAllowlist(map, allowlist, existsInSpecSrc) keeps its typo half (a package the map does not have), its dead-file half (a path not on disk), the already-core and listed-twice checks, and gains a does-not-ship half (a path outside *.zod.ts, which would 404 in a consumer's node_modules). Its reachability half is gone. The selection moved into publishedPointers(coreFiles, closure, allowed, existsInSpecSrc): with no list the closure publishes unchanged; with a list the index publishes the core files plus every declared row that ships, and nothing the closure reached on its own. The docblock explains why a row publishes on existence and records the removed edge in the past tense.
  • packages/spec/scripts/build-skill-references.ts: the guard is handed the disk (existsInSpecSrc), not a closure; the emit loop calls publishedPointers. The up-front closure pass the guard needed is gone.
  • packages/spec/scripts/skill-map-guards.test.ts: an allowlisted, existing, unreachable pointer publishes; an allowlisted dead file is still refused (guard) and never emitted (selection); a non-allowlisted unreachable pointer is not published, with or without a list; a file that exists but does not ship is refused; with a list, a file the closure reached on its own stays out; with no list the closure publishes unchanged; rows come back sorted; the wiring pin asserts the generator hands the guard existsInSpecSrc and no longer reads closures[skillName]. 36 tests pass.
  • Ablation (commit-first, scripts/ablation-replace.mjs, expected direction stated before the run: red): re-introducing the reachability filter in publishedPointers (&& closure.includes(rel), blob 99c90e3e → 1cca6cf1, anchor 1 → 0) turned exactly the two publishing tests red (2 failed | 34 passed) and check:skill-refs red naming skills/objectstack-i18n/references/_index.md (out of date). Restore proven: blob == HEAD 99c90e3e, git diff HEAD empty.

Regenerated indexes (pnpm --filter @objectstack/spec gen:skill-refs, never by hand)

  • objectstack-i18n/references/_index.md: byte-identical to origin/main (git diff --exit-code origin/main -- skills/objectstack-i18n/references/_index.md exits 0). data, ai, ui, platform and formula are unchanged too.
  • objectstack-query (7 → 1 transitive pointers) loses data/field-value.zod.ts, data/field.zod.ts, kernel/metadata-protection.zod.ts, shared/expression.zod.ts, shared/identifiers.zod.ts, shared/value-domain.zod.ts; keeps shared/suggestions.zod.ts.
  • objectstack-api (18 → 13) loses data/field-value.zod.ts, data/field.zod.ts, shared/expression.zod.ts, shared/identifiers.zod.ts, shared/value-domain.zod.ts.
  • objectstack-automation (11 → 7) loses data/field-value.zod.ts, data/field.zod.ts, data/filter.zod.ts, shared/value-domain.zod.ts.
  • None of them is taught by its skill. Over each skill's authored markdown (every .md except the generated references/_index.md): by file stem (field.zod, field-value, expression.zod, identifiers.zod, value-domain, metadata-protection, filter.zod), 0 hits for every removed pointer in its own skill (query's one filter.zod hit names its own core file, which it keeps). By exported identifier, every export name of each removed file, word-bounded: no schema-level name (FieldSchema, FilterConditionSchema, ExpressionSchema, IdentifierSchema, ValueDomain…, MetadataProtection…) appears; the only collisions are ordinary English words some file also exports (Field, Filter, FilterCondition, Expression, expression, cel, cron, P, Predicate), for example the api routing row "Filter, sort, paginate, or aggregate a request → objectstack-query" and the automation heading "Filter tokens (config.filter)", a node-config slot whose shape lives in the core builtin-node-config / io-node-config pointers.

Pin: packages/spec/src/eager-entry-import.test.ts

Every module subpath in spec's own exports map is mapped to its source entry, 17 today. Each entry is imported first in a fresh child process with OS_EAGER_SCHEMAS=1 and must not throw. Two controls keep a green meaningful: under the flag a known lazySchema export on ./data (FieldSchema) and on ./api (ApiErrorSchema) arrives as a built zod object (typeof is object), proving the flag reached the child; without the flag the same entries still import and the export stays the lazy Proxy (typeof is function). Source entries only: @objectstack/spec#test depends on ^build, not its own build, so dist/ is not guaranteed at test time, and check:test-source-alias wants unit verdicts on source. The bundles were measured by hand (below).

Evidence on the merged tree (head 247d0611fc, origin/main 61609edf81 merged via scripts/pm/os-regen-merge.sh)

  • Merge: 326 files from main; on this surface it brought packages/spec/src/shared/lazy-schema.ts (+49 / −2, the landed lazy-schema metadata aliasing) and skills/objectstack-ui/SKILL.md; no _index.md, no skill-map-guards.ts. Step 2 kept the branch's export-origins/root.json and shared.json (branch-only edits). After pnpm install --frozen-lockfile and a spec rebuild, check:generated read all 15 artifacts current, so the regeneration commit the script prescribes was empty.
  • Cycle fix re-measured: the pin passes (Test Files 1 passed, Tests 20 passed: 17 entries under the flag, 2 unflagged controls, 1 mapping). Dist, on the rebuilt dist/ (.build-input-hash 61e345f25156fe1e…): all 44 published JS files (main and browser conditions, .js and .mjs) exit 0 as a first import both with and without the flag (88 runs, 0 failures); on the 8 /data and /api files the probe export reads object under the flag and function without it.
  • Spec suites: vitest run --project local --maxWorkers=2: 533 files passed, 15675 tests passed, 2 todo. --project repo: 31 files, 579 tests passed. typecheck (tsc --noEmit + check:scripts-typecheck + check:test-typecheck): exit 0, 53 files / 255 errors / 142 pinned signatures held.
  • Gates: dispatch-gates --commands --repo objectstack-ai/objectstack derived 97 families on this diff at 247d0611fc; every one was run with its exit code landed on disk and reconciled with --ran: "97 derived famil(ies) accounted for — 95 run, 2 NOT-MEASURED, 0 UNRUN". 95 exit 0, among them check:skill-refs ("9 generated files in sync with packages/spec"), check:generated (15/15 current), check:api-surface (unchanged), check:export-origins (5260 exports across 17 entry points), check:doc-authoring, check:skill-identifier-liveness, check:nul-bytes, check:test-source-alias, check:cross-package-test-inputs, check:pm-governed-merges, check:pm-dispatch-gates (1925 cases). NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 on their own prerequisite (every workspace package built; 66 have no dist/ here); CI builds all.

维护者速读(草稿)

改了什么:两件事。① 文档写的应急回退开关 OS_EAGER_SCHEMAS=1 一打开,@objectstack/spec 的 /api、/data 入口在导入时就崩;修法是把 suggestFieldType 搬到一个只单向引用字段 schema 的小文件里,断开那条导入环。公开导出一字不变。② 按您批的 A:技能说明书的「相关 schema 指针」允许清单,从「导入链够得着才发布」改成「文件存在且随包发布就发布」,只对清单内条目生效;生成器重跑后,objectstack-i18n 的索引逐字节不变,query / api / automation 三份索引各少掉几条从没教过的内部指针。

为什么改:一个会崩的回退开关比没有更糟;运维最需要它的时候正是怀疑延迟加载本身出问题的时候。而 i18n 那两个指针本来就是说明书教的内容,不是导入关系——靠一条偶然的环把它们带出来,恰恰是这张卡要拆的隐患。

风险与代价(含回滚):运行时零风险——只搬了一个函数的声明位置,check:api-surface 读作公开面不变,17 个入口、44 个发布产物在开关下全部实测能导入。生成器一侧的代价是三份技能索引少了几条指针;实测这三份说明书的正文一个都没教过它们。回滚:整体 revert 本 PR 即可,不需要数据迁移。本 PR 改到 skills/**,按规矩 Tier H,由您合并。

席位意见:建议合并。达档复核 PASS(5820632479,head 247d0611fc):裁决 A 逐条兑现,只对允许清单生效、不外扩;objectstack-i18n 索引逐字节不变;三份少掉的指针复核员逐一对过各自技能正文,全部没教过;公开导出面逐字节不变;两道围栏都守住。CI 32 项通过、3 项按设计跳过。

你要做的:确认 query / api / automation 三份索引少掉的指针清单(上文列出)可以接受;可以就合并。不合并也不会更糟——只是回退开关继续崩、门禁继续红。

Acceptance notes (noted, not filed)

  • Several comments still describe the removed edge in the present tense; the defensive code under them (hoisted functions, deferred error maps, lazily built sets) stays valid and is not removed here. Sites: the shared/strict-object.ts docblocks around declarationStore, strictObjectError, PrimableErrorMap and closedObjectConstructor; shared/strict-object.test.ts (the eager-construction block); data/field-value.zod.ts:35; data/field.zod.ts:184, fenced by the PR holding that file. The scripts/lib/skill-map-guards.ts docblock is rewritten in this PR because ruling A changes what it explains. Carrier: none.
  • The first-import crash reported for the two refinement-projection modules did not reproduce at fdeeea0cc9 (see above).

Contract review

No accept or reject result changes, and the public export surface is byte-identical per check:api-surface; only the declaration site of one function moves, plus generator tooling and its generated skills/** output. The at-tier contract review the ruling asks for is the seat's record on the card. This PR touches skills/**, so it lands Tier H on the maintainer's merge.


Generated by Claude Code

…mport cycle

`suggestions.zod.ts` is imported by `strict-object.ts`, which nearly every
closed schema imports; its value import of `FieldType` from `data/field.zod`
closed the loop filter.zod -> strict-object -> suggestions.zod -> field.zod.
Under OS_EAGER_SCHEMAS=1 the eager FieldSchema factory then read
FilterConditionSchema before filter.zod finished, so the `/api` and `/data`
entries threw at import.

`FieldType` was only read when a suggestion is asked for, so `suggestFieldType`
(and its alias table) moves to `shared/field-type-suggestion.ts`, which imports
field.zod one-directionally. Public names are unchanged: it is still exported
from the package root and `./shared`.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
Each `exports` subpath's source entry is imported first in a fresh child under
OS_EAGER_SCHEMAS=1 and must not throw. Two controls: under the flag a known
lazySchema export on `/data` and `/api` arrives built (typeof object), so the
flag reached the child; without the flag the same entries import and the
export stays the lazy Proxy (typeof function).

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
`suggestFieldType` now resolves from `shared/field-type-suggestion.ts`; the
suite's import follows it, and `export-origins/` records the new declaration
site for the root and `./shared` entries (names and kinds unchanged).

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 17 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/export-origins/root.json, packages/spec/export-origins/shared.json, packages/spec/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 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 b81da66df773b5d237cad04d6b05a61e9544d553.

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

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/export-origins/root.json, packages/spec/export-origins/shared.json, packages/spec/src/index.ts, …) — pages documenting those are invisible to this run
  • 40 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 136 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 b81da66df773b5d237cad04d6b05a61e9544d553 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 b81da66df773b5d237cad04d6b05a61e9544d553 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…reachability

`TRANSITIVE_ALLOWLIST` rows were a filter over the import closure: a row
published only when some import edge also reached the file, and the guard
refused a row the closure missed. Both `objectstack-i18n` rows were reached
through the `suggestions.zod -> field.zod` edge that this branch removed to
break the eager import cycle, so `gen:skill-refs` refused and
`check:skill-refs` went red.

A row is now a DECLARED pointer: it publishes when the file exists under
`packages/spec/src` and ships (`*.zod.ts`), for packages with a list only.
The guard keeps its typo / dead-file half and gains a does-not-ship half;
its reachability half is gone. `publishedPointers()` carries the selection
so it is unit-testable: an allowlisted, existing, unreachable pointer
publishes; an allowlisted dead file is still refused; a non-allowlisted
unreachable pointer is still not published; a package with no list keeps
its full closure.

Regenerated with `gen:skill-refs`: `objectstack-i18n` is byte-identical to
main; `objectstack-query` (-6), `objectstack-api` (-5) and
`objectstack-automation` (-4) lose only pointers reached through the removed
helper edge, none of which their authored markdown names.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Sep 24, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 88/88 CONTRACT_REVIEW_TIER
Head-sha: 247d0611fc164e95aedc91b213bd0642515b5760

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 88 transcript turns served at the tier the constant names. Continuation round after ruling A (5814528240). Landing class Tier H (skills/**): the maintainer's merge. Adopted by the seat 2026-09-24T19:18Z. The record below is the reviewer's, unedited except the two header lines.

Inputs read: card #19930 body and all 8 comments (triage 5805174208, claim 5805510105, round-1 report 5806147988, decision analysis 5806180472, release 5806192723, ruling 5814528240, re-claim 5819463080, newest os-dev-report 5820368633); pulls/19943 (draft, base main at merge-base 61609edf8111aafe2285a0cb8cdd53b240b6d76e, 17 files, 6 commits, +525/−176), pulls/19943/files, the PR's one issue comment (docs-drift bot), zero review comments, zero reviews; commits/247d0611fc…/check-runs (35 runs: 32 success, 3 skipped, 0 failure); git reads on refs/review/pr-19943 and origin/main (now fc6ddb87a4, which has NOT moved any file under skills/, packages/spec/scripts, packages/spec/src/shared, field.zod.ts, src/index.ts, export-origins or api-surface since the merge-base: git diff --name-only 61609edf8 origin/main -- those paths is empty); AGENTS.md at origin/main; the seat's body-proposed.md. Nothing was written to GitHub or to any repository file; scratch only under scratchpad/pr-19943/review/.

① Derived judgments

Ruling A, quoted from 5814528240: "An allowlisted pointer publishes when the file exists and ships (*.zod.ts), whether or not the textual import closure reaches it. The guard keeps its typo / dead-file half, and its reachability half becomes an existence check for that list only. objectstack-i18n's _index.md stays byte-identical. query, api and automation lose only the helper-edge pointers their skills never taught. Every _index.md is regenerated by gen:skill-refs, ⛔ never by hand. ⛔ D refused … ⛔ B not taken."

(a) The head implements A exactly; no widening beyond the list.

  • Publication condition = exists AND ships: refs/review/pr-19943:packages/spec/scripts/lib/skill-map-guards.ts:287-289 shipsFromSpec(rel, exists) returns rel.endsWith('.zod.ts') && exists(rel); publishedPointers (lines 361-373) adds each allowed row on shipsFromSpec alone and never consults closure for it.
  • "Whether or not the closure reaches it": my independent re-derivation of the generator (script review/sim-closure.mjs, replicating extractLocalImports/resolveAll/publishedPointers over a git archive of the head) reports for objectstack-i18n allowed-not-in-closure=[shared/identifiers.zod.ts,data/field.zod.ts] — both rows are unreachable at the head and publish only by the new rule; at the merge-base the same list reads allowed-not-in-closure=[].
  • "For that list only": publishedPointers line 366 if (allowed === undefined) return [...closure]; — a package with no list publishes its closure unchanged, exactly the pre-change allowed === undefined ? resolved : … at 61609edf8:packages/spec/scripts/build-skill-references.ts:438-441. The existence predicate is applied only to TRANSITIVE_ALLOWLIST rows (build-skill-references.ts:418-419 and :437 at the head).
  • Package WITH a list: pre-change published resolved.filter((f) => coreSet.has(f) || allowed.includes(f)) (merge-base build-skill-references.ts:441), i.e. closure-only files were already excluded; post-change publishes closure ∩ core plus shipping allowed rows. Nothing that "the closure reached on its own" newly drops — it was never published for a listed package. Both listed packages (objectstack-platform, 5 rows; objectstack-i18n, 2 rows) produce identical pointer sets before and after (sim: platform transitive=5 → 5, i18n 2 → 2). Packages without a list shrink only because the import edge is gone, which is what the ruling's third sentence accepts.
  • Guard halves at the head (skill-map-guards.ts:310-345): typo half kept (package not in map), already-core and listed-twice kept, dead-file half now !exists(rel) (line 336), the reachability branch !closure.has(rel) deleted, plus a new refusal for a path outside *.zod.ts (line 330). That extra half enforces the ruling's own "ships (*.zod.ts)" clause on a hand-authored row and widens nothing that publishes (see ③, non-blocking).
  • Wiring: build-skill-references.ts:418-419 hands the guard existsInSpecSrc (disk), the up-front closures map is gone; git grep 'closures\[skillName\]\|core closure imports it' 247d0611fc -- packages scripts has no hit outside the guard test's negative assertion.

(b) i18n byte-identical; the three removals are helper-edge pointers only.

  • git diff --exit-code 61609edf8 247d0611fc -- skills/objectstack-i18n/references/_index.md exits 0; the same against current origin/main fc6ddb87a4 exits 0; blob ce2cbeff8ba677510c0556703b4b37411d5f63f4 at all three refs. git diff --name-status 61609edf8 247d0611fc -- skills/ lists exactly api, automation, query _index.md (the other six indexes unchanged).
  • Removed rows read off the diff: query −6 (data/field-value, data/field, kernel/metadata-protection, shared/expression, shared/identifiers, shared/value-domain; keeps shared/suggestions), api −5 (field-value, field, expression, identifiers, value-domain), automation −4 (field-value, field, data/filter, value-domain). Sim counts: query 7→1, api 18→13, automation 11→7. The diffs are pure deletions; no retained row's description changed.
  • Authored markdown check (every .md in each skill except references/_index.md, at the head): git grep -n -E 'field-value|field\.zod|metadata-protection|expression\.zod|identifiers\.zod|value-domain' 247d0611fc -- skills/objectstack-query ':!…/_index.md' → 0 hits; api pattern → 0 hits; automation pattern (with filter\.zod) → 0 hits. By exported identifier (script review/ident-grep.mjs, word-bounded over every export name of each removed file): 0 schema-level names; the only hits are ordinary words — Field (skills/objectstack-query/rules/filters.md:115 "## Field References", skills/objectstack-automation/references/state-machines-and-approvals.md:344 a table header), expression (skills/objectstack-query/SKILL.md:6, rules/filters.md:242), Filter (skills/objectstack-automation/SKILL.md:200 "### Filter tokens (config.filter)"); api: 0 hits of any kind. The body's api example row exists at skills/objectstack-api/SKILL.md:29.

(c) Public surface identical; the pin is weight-bearing with real controls.

  • Same name and signature: 247d0611fc:packages/spec/src/shared/field-type-suggestion.ts:122 export function suggestFieldType(input: string): string[] vs merge-base suggestions.zod.ts (deleted hunk) — the alias table (58 rows) and the function body are byte-identical; it still calls findClosestMatches(normalized, FieldType.options) with findClosestMatches imported from ./suggestions.zod (line 5) and FieldType from ../data/field.zod (line 3).
  • Both entries: root barrel src/index.ts:218-219 (export { findClosestMatches, formatSuggestion } from './shared/suggestions.zod'; export { suggestFieldType } from './shared/field-type-suggestion';), shared barrel src/shared/index.ts:22 (export * from './field-type-suggestion'). Checked-in surface baseline packages/spec/api-surface/{root,shared}.json:229/128 carry "suggestFieldType (function)" at both refs and git diff --stat 61609edf8 247d0611fc -- packages/spec/api-surface is empty; export-origins/{root,shared}.json moved the origin to src/shared/field-type-suggestion.ts#suggestFieldType (function) (the only two lines changed). No file at the head imports suggestFieldType from suggestions.zod (git grep empty); the one out-of-package consumer, packages/metadata-core/src/object-field-type.ts:26, imports from @objectstack/spec/shared, still served.
  • Cycle really cut: suggestions.zod.ts at the head imports only ./alias-probe and ./polarity-axes (lines 5-9), both of which import nothing.
  • Pin packages/spec/src/eager-entry-import.test.ts: derives its 17 entries from package.json exports (17 module subpaths + 2 data files confirmed from 247d0611fc:packages/spec/package.json), spawns a fresh node --import tsx child per entry with OS_EAGER_SCHEMAS=1 (lines 82-108), asserts exit 0. Controls are real: 247d0611fc:packages/spec/src/shared/lazy-schema.ts:34-37 returns factory() under the flag (a zod object → typeof object) and otherwise a Proxy over function lazyZod() {} (line 45, 125 → typeof function); FieldSchema (data/field.zod.ts:1000) and ApiErrorSchema (api/contract.zod.ts:15) are both lazySchema-wrapped and reach ./data / ./api via export * (data/index.ts:132, api/index.ts:16). 1 mapping + 17 flagged + 2 unflagged = the 20 tests reported. The ablations (A: re-close the edge → exactly ./data/./api red; B: break the env hand-off → control 1 red) are the dev's runs, not re-run here (gate/test suites are off-limits to this review); the blob ids they cite match the head (suggestions.zod.ts 4ed9b1d8c6…, eager-entry-import.test.ts 9e2b7b0fb5…, skill-map-guards.ts 99c90e3e76…).

(d) Fences held. Per-commit file lists (git show --format= --name-only) of the five authored commits 30ebee700c, 1b2a2bb592, 7d8a3800ef, fd9f55aadf, 247d0611fc contain neither packages/spec/src/shared/lazy-schema.ts nor packages/spec/src/data/field.zod.ts. Merge 1cda062eca (parents fd9f55aadf, 61609edf81): git diff --stat 61609edf8 1cda062eca -- lazy-schema.ts field.zod.ts is empty (it carries main's lazy-schema.ts byte-exact, +49/−2 vs the first parent), and git diff --stat 61609edf8 247d0611fc on both fenced files is empty. The merge's diff against main touches exactly the 11 round-1 files, and its hunks are identical to git diff fdeeea0cc9 fd9f55aadf (no branch-side edit smuggled into the merge).

(e) Changeset and Clause-② consistent. 247d0611fc:.changeset/19930-eager-first-import-cycle.md:1-3 "@objectstack/spec": patch; body states no accept/reject change and unchanged exports. AGENTS.md at origin/main line 1070-1072: a bug fix in a released package takes patch; Clause-② yes = "this PR puts a new key on a published payload" (scripts/check-changeset-no-major.mjs:884-885) and must grade at least one moved package minor. A declaration-site move with an unchanged api-surface baseline adds no key and no export, so Clause-②: no with patch is the consistent pair; Check Changeset is green. scripts/ and skills/ are outside the package files allowlist (dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json), so the round-2 files publish nothing and the round-1 changeset still describes everything that ships.

(f) The _index.md files are the generator's output. Check-run Type Check · source gates (job 107770057651) at head 247d0611fc… completed success, step "Check generated skill references are in sync with the spec" (pnpm --filter @objectstack/spec check:skill-refs) success; "Check generated reference docs…", "Check the export-origins baseline resolves as recorded", "Published skills token ratchet" all success. Independently, node review/sim-closure.mjs head reproduces all 9 indexes at the head ("ALL 9 MATCH", pointer sets and emit order) and … mb reproduces all 9 at the merge-base with the pre-change filter, giving the 7/18/11 → 1/13/7 transition from the generator's own logic, not from hand edits.

Landing class: skills/** is on the Tier H row (AGENTS.md Prime Directive #14, lines 257-273: "one Tier H path makes the whole PR Tier H … Tier H (人合: … skills/** …)"), and the ruling 5814528240 says "The PR then lands Tier H (skills/**) on the maintainer's merge". ⇒ Tier H, the maintainer's merge; no seat lands, queues or arms it.

Proposed body (body-proposed.md) against the head: every sentence I could test holds — Fixes #19930 / Clause-②: no; the load-chain line refs at fdeeea0cc9 (filter.zod.ts:4, strict-object.ts:83, suggestions.zod.ts:4, field.zod.ts:1000, field.zod.ts:1529 relatedListFilter: FilterConditionSchema.optional()) are exact; the three importer edits, the export-origins lines, the guard halves, the publishedPointers contract, the wiring pin, 36 tests (31 it( + 5 it.each rows), the ablation blob ids, i18n byte-identity, the three removed-pointer lists, "17 today", 44 dist JS files in the exports map, 326 files in the merge with lazy-schema.ts and skills/objectstack-ui/SKILL.md from main and no _index.md/skill-map-guards.ts, 5260 export-origins entries across 17 files, 17 files +525/−176 whole-branch and 6 files +193/−74 this round, the acceptance-note comment sites (field-value.zod.ts:35, field.zod.ts:184, strict-object.ts:213/227/318, strict-object.test.ts:438/488 still describe the old edge). Not independently reproducible here (local measurements, not gate output): the 1039/1040-module sweep counts, the dist .build-input-hash, the vitest/typecheck totals — CI's green runs corroborate the gate-level claims. One imprecision, noted in ③.

② Semver level

patch on @objectstack/spec is right. No export added, removed or renamed (api-surface baseline byte-identical at root.json:229 and shared.json:128); suggestFieldType keeps its name, (input: string): string[] signature, alias table and answers; no schema accepts or rejects differently; scripts/ and skills/ are not published by npm. Clause-②: no is the only consistent declaration for a diff that puts no new key on any published payload; a minor would be a self-contradiction under check-changeset-no-major.mjs.

③ Boundary flags

  • non-blocking — The guard gained a fourth refusal, "not a *.zod.ts path" (skill-map-guards.ts:330-334), beyond the ruling's enumerated "typo / dead-file half". It applies to allowlist rows only, enforces the ruling's own "ships (*.zod.ts)" condition, and refuses loudly rather than dropping a pointer silently; nothing that publishes is widened.
  • non-blocking — The register carve-out for generator-owned skills/** outputs (scripts/pm/check-governed-merges.mjs:385-411, maintainer ruling [finding] skills/*/references/_index.md is generator-owned (gen:skill-refs) yet sits inside the skills/** governed-merge fence — every spec PR touching an indexed headline is forced to carry a governed-surface file #11705) can lift a _index.md-only path hit when the checker proves per-file reproducibility. The ruling on this card nonetheless names Tier H on the maintainer's merge, so the seat should treat this PR as Tier H regardless of what the queue guard would compute.
  • non-blocking — Merge commit 1cda062eca carries no Claude-Session / Co-authored-by trailer pair; it is the git merge --no-edit auto-commit that scripts/pm/os-regen-merge.sh prescribes (its own comment at line 221). The five authored commits carry the model-free pair AGENTS.md line 451-452 requires.
  • non-blocking — Body wording: "packages/spec/src/shared/lazy-schema.ts (+51, …)" — the merge's change to that file is +49/−2 (51 lines touched, not 51 additions). Suggest "51 lines" or "+49/−2".
  • non-blocking — The ruling's remaining execution steps are not yet visible on the PR: it is still draft, requested_reviewers is empty (ruling asks for os-zhuang / hotlong), and the 维护者速读 lives in the proposed body, not yet on the card. These are the seat's post-review actions, not defects of the head.
  • non-blocking — Historical prose still describing the removed edge (docs/audits/2026-07-unknown-key-strictness-ledger.md:399, dated ledger; docs/qa/platform-checklist/FOLLOW-UPS.md:306) is not shipped customer text and states nothing about the declaration site; consistent with the body's "noted, not filed" acceptance notes.
  • non-blocking — origin/main advanced to fc6ddb87a4 after the merge; the four added files are unrelated .changeset/*.md entries and nothing on this PR's surface moved, so the head needs no re-merge for review purposes.

Implemented-by: claude/issue-19930-eager-import-cycle
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 24, 2026 23:48
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 2274894 Sep 25, 2026
44 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-19930-eager-import-cycle branch September 25, 2026 00:17
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 protocol:data size/l tests tooling

Projects

None yet

2 participants