Skip to content

fix(spec,objectql): declare the inert-JSON artifact and registry-record package body stages, and stop the record under-reporting functions - #19373

Merged
os-litant merged 17 commits into
mainfrom
claude/issue-17518-assembled-package-body-inert-json
Sep 22, 2026
Merged

os-litant merged 17 commits into
mainfrom
claude/issue-17518-assembled-package-body-inert-json

Conversation

@os-litant

@os-litant os-litant commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #17518

Clause-②: yes

Executes ruling A′ — decision batch #192 item 3, comment 5748934194, maintainer 「192 同意」. Its two steps, its refusals (A and B) and its fences are followed as written; every place where the tree made me read the ruling rather than transcribe it is called out below.

Base of every reading in this body: regeneration commit 96dd3549ff6, the head of the SIXTH merge.

⚠️ The readings below were brought to this head by the seat, not by the round that first wrote them. Two merge rounds have run since the first draft. Each figure corrected here is named in the correcting round's own report on card #17518 — comment 5750725852 for the first, 5750987577 for the second — and the seat re-verified the head, the regenerated index and mergeability itself before editing. Anything not listed in those two reports is the original round's reading, unchanged.

The confidence gap the ruling asked me to close first

「whether effect is required or defaulted on the declaration schema — read it, ⛔ do not mint a value」

Defaulted. FlowFunctionDeclarationSchema.effect is FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT) where that constant is 'pure' (automation/flow-function.zod.ts). Measured, not read off the source alone: FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' }) succeeds and yields { handler: 'x', effect: 'pure' }. The array member of functions states FlowFunctionEffectSchema.optional() with no default, so the two forms differ and neither is restated anywhere in this diff — each JSON stage inherits its form's own optionality by deriving from it.

That reading is what the producer writes: the bare-callable normalisation uses DEFAULT_FLOW_FUNCTION_EFFECT and the array form gets nothing.

What landed

packages/spec/src/automation/flow-function.zod.ts — FlowFunctionLoweredDeclarationSchema is exported (step 1), with its FlowFunctionLoweredDeclaration / …Parsed aliases. It was a module-local const, and automation/index.ts's export * only re-exports what is already exported.

packages/spec/src/stack.zod.ts — two new bodies beside AssembledPackageBodySchema:

  • ArtifactStagePackageBodySchema — the on-disk artifact stage. functions entries are the lowered spellings, hooks[].handler is a string.
  • RecordStagePackageBodySchema — the registry record stage: literally ArtifactStagePackageBodySchema.extend({ functions: … }) with functions[].handler optional in both the map-record form and the array form, and nothing else.

AssembledPackageBodySchema, composeStacks and the cannot drift invariant are ⛔ untouched: those callables are live on the stage the assembled body declares itself for, and narrowing it would refuse a published composition function's own output. Both new schemas carry the same structural z.ZodType annotation as the assembled body, for the two reasons recorded there (TS7056; a named alias turning stack.zod into a shared chunk).

packages/spec/src/api/package-api.zod.ts — the installed-package row's manifest is rebound to the record stage (step 1). The z.unknown() override and the docblock defending it are gone, and the sentence that ruling A step 5 assigns to this edit is corrected in place: those two members are not why ArtifactPackageSchema and ObjectStackDefinitionSchema publish no JSON Schema — src/stack.zod.ts is not one of the subpath namespaces build-schemas.ts walks, so neither is ever reached by the emit loop.

packages/objectql/src/registry.ts — step 2. withDeclaredFunctionEntries rewrites a bare callable functions map entry to { handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT } at the assembly boundary, before toRecordManifest runs. toRecordManifest's structural rule is ⛔ untouched and no key is special-cased inside the projection; the two spellings are simply made structurally equal ahead of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest is never mutated and a copy is made only when an entry really needed rewriting.

Two places where I read the ruling rather than transcribed it — both stated so they can be overruled

  1. 「functions entries the lowered declaration」 is implemented as BOTH lowered members of FlowFunctionEntrySchema, not only the record one. objectstack build emits { myFn: 'myFn' } for a bare entry and { myFn: { handler: 'myFn', effect } } for a declared one, so a stage admitting only the record form would refuse artifacts this repo really writes — the failure mode that withdrew letter B, one key across. Ruling A′'s own step-4 control names both shapes (「a string and a lowered record」). Measured: the artifact stage accepts a body carrying one of each.
  2. The array member is transcribed, not derived. functions' array branch is declared inline inside the assembled body's own shape, and narrowing it in place is the one thing this pair may not do. The transcription's drift is guarded instead: stack-json-stage-package-body.test.ts pins the authoring array entry's key set equal to both JSON stages', so a key added there and not here reddens by name.

Acceptance, as ruling A′ lists it

criterion result
both bodies convert under z.toJSONSchema (self-test over the whole body) YES / YES; control: the assembled body still NO (Function types cannot be represented in JSON Schema); probe controls lit z.string() YES, dark z.object({a: z.function()}) NO
the showcase-shaped manifest (config.ts:244-249) reports 2 functions on the GET /packages row, the bare one as a handler-less declaration 2: {"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}, driven through the real SchemaRegistry.installPackage
hooks unchanged unchanged: an inline handler is dropped (the key is optional and admits that), a string handler survives verbatim. The array functions form also keeps its entry: [{"name":"syncBilling","effect":"writes"}]
AssembledPackageBodySchema / composeStacks / the invariant untouched untouched — no edit in those regions; assembled-package-body.test.ts and compose-stacks-manifest-preserve.test.ts stay green
the two noted, not filed corrections in the same edit baseline reason line: made TRUE by step 1 rather than reworded — automation/FlowFunctionLoweredDeclaration is now in json-schema.manifest/automation.json, so 「the lowered record … publishes normally」 is now a fact. package-api.zod.ts docblock last sentence: corrected in place, see above

Stage separation, measured rather than asserted: the record stage accepts the handler-less declaration and the artifact stage refuses it; the assembled body accepts a live callable and both JSON stages refuse it; both JSON stages still refuse an authoring glob and an unknown key (namesapce). So the two keys moved from unknown to a declaration, and nothing else moved.

Reverse verification — two ablations, each restored with proof

Both ran against committed code, each with a trap restore, an on-disk landing proof (anchor grep -c before/after plus a blob-hash change) and a restore proof (git hash-object back to the HEAD blob, git diff HEAD empty).

  • A1 — remove the producer normalisation (toRecordManifest(withDeclaredFunctionEntries(manifest)) → toRecordManifest(manifest); anchor 1→0, injected 1, blob b0af60d7… → 17b7c93c…): registry-package-manifest-serializable.test.ts goes 1 failed / 15 passed, naming the exact defect — expected [ 'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1) ]. Restored blob b0af60d7…, diff empty.
  • A3 — collapse the record stage into the artifact stage (jsonStageFunctionsKey(true) → (false); anchor 1→0, injected 2, blob 60c13b43… → 822bed8e…): 2 failed / 79 passed across two files — record accepts the handler-less declaration; ⛔ the ARTIFACT stage refuses it and parses a row carrying the residual the projection really produces. So the one-key difference that IS the fourth stage is load-bearing in both packages' pins. Restored blob 60c13b43…, diff empty.

No ablation is offered for 「both bodies convert」: that claim already carries its discriminating control inside the same test file (the assembled body must NOT convert), which is a lit/dark pair rather than an assertion about itself.

Tests and gates

All through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-17518, verdicts read from the wrapper's own VERDICT command-exit line and never a bare $?; every exit code captured before any pipe. Wall-clock figures in the logs are SHARED-BOX seconds.

  • pnpm --filter @objectstack/spec test — 513 files / 14971 tests passed, 1 todo — the FULL suite, re-run on this head because the sixth merge carried 128 commits of base movement including breaking spec changes
  • pnpm --filter @objectstack/objectql test — 303 files / 5057 tests passed
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 over the package-door / artifact population, enumerated by a name match on packages/runtime for package or artifact so the population is reproducible — 39 files / 512 tests passed. ⚠️ The first attempt exited 1 in 2 seconds and is recorded as NOT a red: the paths were repo-root-relative while pnpm exec runs at the package root, and the repo's own guard said so in words (FILTER SELECTED NOTHING — 39 of the 39 path(s) you named will run no tests). Re-run with package-relative paths for the reading above.
  • pnpm --filter @objectstack/spec --filter @objectstack/objectql typecheck — exit 0; both test layers compile (spec 53 files / 257 errors / 142 pins; objectql 40 / 234 / 65, unchanged). ⚠️ The spec ledger moved from 54 / 259 / 144 by main's feat(spec): the /packages doors declare the query parameters they execute, and retire the two they never did #19364 arriving in a merge, ⛔ not by this PR.
  • pnpm --filter @objectstack/spec --filter @objectstack/objectql typecheck — both exit 0 on this head; the debt ledgers held shrink-only (spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 / 65).
  • pnpm --filter @objectstack/spec build exit 0 (34/34 declared .d.ts present, check-dts-references resolved 378/378), and the whole @objectstack/runtime dependency closure was rebuilt first, so nothing below read a dist stale against 128 commits of main.

Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived from this tree, every command run with its exit code written to a file, reconciled with --ran: 116 derived, 114 run, 2 NOT-MEASURED, 0 UNRUN, and the tool's own verdict line says so. 113 exit 0. The two NOT-MEASURED are the tool's DERIVED classification of an exit 3; a third measured nothing too, and the tool cannot see it because its refusal code is 2. ⛔ None of the three is a finding:

  • check:dual-build-cjs-loads — exit 3, its own PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured (66 packages have no dist; it wants a whole-repo build).
  • check:type-check-debt — exit 3, same shape, same wording, wants the full package closure built.
  • check-engine-split-ratio --days 90 — exit 2, refuses on a shallow clone whose oldest visible commit sits inside the 90-day window. It says a ratio derived there would be 「real, plausible and WRONG」.

A fourth, check:skill-examples, first exited 1 on an unbuilt packages/client-react; after building that package it re-runs green — 258 prose examples type-check across 3 surfaces. Both readings are stated here, and the reconciliation record carries ONE of them — the green re-run — because the tool flags a doubly-recorded family and says to make the record state one thing. The re-derivation on the final head yields 116 families: check:api-surface-declarations is gone (retired upstream by #19024 mid-round) and check:gitlink-declared is new, run green. No family is left unrun.

Ratchet families re-run after the last merge, on 96dd3549ff6: check:generated (all 15 artifacts up to date), check:api-surface, check:authorable-surface, check:export-origins, check:declaration-map, check:docs, check:skill-refs, check:entry-nameability, check:dual-source-exports, check:spec-changes, check:spec-parsed-alias, check:published-files, check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage — all exit 0. Control characters: grep -naP over every file I hand-edited returns nothing (exit 1).

Generated artefacts in this diff, and why each moved

  • json-schema.manifest/automation.json, authorable-surface/automation.json, authorable-defaults/automation.json, api-surface/*, export-origins/*, declaration-map/automation.json, content/docs/references/** — the new exports, regenerated by the package's own gen: scripts. authorable-defaults records automation/FlowFunctionLoweredDeclaration:effect = "pure", which is the confidence-gap reading in ledger form.
  • packages/spec/dropped-refinements.baseline.json — four api/* entries each gain one site (…manifest.hooks.element.object), counts 569 → 573. Cause: the record stage declares hooks where z.unknown() declared nothing, so HookSchema's object refinement now reaches the runtime and not the published file. The ledger is hand-edited by design and the build printed the exact delta.
  • skills/objectstack-platform/references/_index.md — one generated line listing stack.zod.ts's exports.

skills/** readings, and the landing tier

This diff touches skills/objectstack-platform/references/_index.md, so the PR is governed, Tier H on its file list. ⛔ It stays a draft and no AI seat merges, queues or arms auto-merge on it.

Both readings the skills rule requires, at merge base c334ba0f3a6:

  • changed file, whole file: 41 lines before, 41 after — net 0. The diff is one regenerated line.
  • package total (sum of every SKILL.md): 6145 before, 6145 after — net 0.

node scripts/check-skills-token-ratchet.mjs exits 0 and classifies this file as generator-owned (measured, not ratcheted), so no authored ceiling is charged.

Clause ②, and the changeset is not one package's

Clause-②: yes, and two changesets because two published packages move:

  • @objectstack/spec — minor. New exports, and the two installed-package responses move from z.unknown() on functions / hooks to declared JSON shapes. That is a narrowing on a published declaration; what it does NOT withdraw is measured, on real producers: the showcase shape, the array form and the already-lowered body an artifact boot installs all parse.
  • @objectstack/objectql — patch. GET /packages reports functions it previously dropped. No API is added or removed; a read door stops under-reporting. Grade it up if a payload gaining entries reads as minor to the reviewer.

Serial and merge state, re-taken by this seat

Changed-file map re-taken first-hand over all 33 open PRs (271 file rows) rather than inherited. LIT control packages/spec/src/ui/action-params.zod.ts resolves to #19315; DARK control packages/spec/src/zzz-no-such.zod.ts resolves to nothing.

origin/main has been merged six times on this branch. #19024 (which retired api-surface-declarations/) came in early, which is why no api-surface-declarations/*.txt appears in this diff. The fifth merge brought #19363, a BREAKING spec change. The sixth merge, the head of this body, brought 128 commits — so the full spec suite was re-run rather than only the generated gates.

⛔ scripts/pm/os-regen-merge.sh was NOT used in either round — its rerun arm is re-entrant and commits a revert of the operator's own regeneration, filed as #19392. Steps 1–3 of its documented order were performed by hand, against a merge base captured BEFORE the merge and an origin/main fetched into an OWNED ref so a sibling's fetch could not move the target mid-round.

The sixth merge decided THREE paths, and only one of them was a conflict. That gap is worth stating, because resolving only what a conflict probe names would have landed a silent loss:

path routed what the merge did how it was resolved
content/docs/references/index.mdx merge=os-regen driver deferred it, exit 0 — main's side silently dropped (merged blob 6290447bd9a == ours, != theirs 7e1f9b6f13e) main's side restored into the WORKING TREE ONLY, then regenerated whole
content/docs/references/api/package-api.mdx merge=os-regen same — main's side silently dropped (merged 988bedaa480 == ours, != theirs d09cd420711) same
packages/spec/dropped-refinements.baseline.json not routed exit 1 — the only real text conflict, one hunk, confined to three summary counters in the measured header both sides' entries unioned, then the build adjudicated

⚠️ package-api.mdx appears in NO conflict list and never could. It text-merges cleanly driver-free, so a GitHub-condition probe cannot name it; only the both-edited ROUTED set, computed per file against the pre-merge base, finds it — which is exactly what os-regen-merge.sh step 2 specifies and what the driver's own $GIT_DIR/os-regen-pending record listed.

The regenerated docs are the UNION, proven in both directions (added/removed line multisets compared as sets): package-api.mdx identical at 20 and 14 lines; index.mdx identical at 12 and 6 lines, excluding the two running-total lines — a union MUST move a total neither side moves alone, so their disagreement is the signature of a correct union rather than a failure, and the line counts already matched (16/16, 10/10) before excluding them. The total is re-derived, not arithmetic: base 1533, this branch alone 1534, main alone 1534, merged tree 1535, and 1535 is what gen:schema itself reports for the merged sources. Main brought DatasetSelection, DatasetCompareTo and DatasetTotals and retired KernelSecurityScanResult / KernelSecurityVulnerability; this branch brought FlowFunctionLoweredDeclaration. All survive, asserted through the published export map of the freshly built dist with a dark control (an invented export name reads undefined).

The ledger was resolved by hand, and that is the only route available. dropped-refinements.baseline.json is hand-edited BY DESIGN with no gen: script — its own description states why: "a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end." The build VALIDATES it bidirectionally and refuses; it never writes it. Both sides' entries were unioned (union keys missing from the merged file: none; merged keys not in the union: none; api/DatasetSelection arrived from main via #19638 and survives; main's removal of the fields.out.keyType sites is kept — nine site lines at the merge base, zero at this head and zero on main (lit control: 204 "sites" keys at base; dark control 0). ⚠️ The merge round's own prose said five; that was a narrative miscount caught by the merge-delta review and re-counted by the seat. The FILE was always right), then gen:schema adjudicated and measured 565 dropped sites across 205 published schemas — the union as resolved. One counter the build corrected: refinementSitesThatDidProject read 357 and the build measures 366.

⚠️ That correction is filed as #19681, because nothing in the repository would have caught it: two of the four measured counters have no reader anywhere (lit control — the other two have two readers each, dark control 0), so they can hold any number and every gate stays green.

Acceptance notes

  • noted, not filed: regenerating packages/spec/api-surface-declarations/ui.txt produced a 184-line change that is a pure permutation of its own content — the same union members in a different order, 0 removed, 0 added, 35 reshaped. Verified as a precedented shape rather than a defect: commit 24d622b94b8, a spec change touching zero files under packages/spec/src/ui/, moved the same file by 5 lines whose sorted content is byte-identical. The whole artefact was retired upstream by revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024 mid-round, so nothing of it survives in this diff and the population is gone. Carrier: none — the file no longer exists.
  • noted, not filed: packages/objectql's tests resolve @objectstack/metadata-protocol from dist, so after merging upstream The in-process installPackage door ignores the enableOnInstall its own request schema declares — honour it the way the HTTP door does (successor of #18605, ruling batch #153 item 5 letter 1) #19277 the seven assertions in protocol-install-package-enable-on-install.test.ts failed against a stale build of a package this PR never touches; building that one package turns all seven green. A local-environment reading, not a repo defect, and check:test-source-alias already owns the aliased/unaliased ledger this sits in. Carrier: the next seat that runs objectql's suite after a merge — it will see the same red and should build the dependency before reading it as a finding.

维护者速读(草稿)

改了什么 —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact 的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:ArtifactStagePackageBodySchema(落盘 artifact)和 RecordStagePackageBodySchema(注册表记录),放在既有的装配体旁边,装配体一个字不动。同时修好一个生产者缺陷:GET /packages 以前会把「裸写的函数」整条漏报,现在两种写法都报。

为什么改 —— 两件事各有代价。其一,装配体里有两个键(functions、hooks)声明了「可以是一个活的函数」,而 JSON Schema 表达不了函数,于是任何嵌入它的接口都会整份丢掉自己的 JSON Schema;读 API 只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 GET /packages 只报 1 个——机器可读的读门把事实说少了。

风险与代价(含回滚) —— 风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase 的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与 composeStacks 未动,所以 os dev / os serve 的行为不受影响——这正是上一版裁决 B 被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,git revert 任一半都不会让另一半变红;最小回滚是把 package-api.zod.ts 的那一行绑回装配体,新声明留着不用。

席位意见 ——

你要做的 —— 这个 PR 的文件里有一份 skills/** 的生成文件,按规则整单属于 Tier H,只有你(或你授权的批准)能让它落地;AI 席位不会合并、不会排队、不会解除 draft。请看两点:① @objectstack/objectql 我打的是 patch,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算 minor,说一声即可改;② functions 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1 条),如果裁决本意是只收记录式那一种,也请直接说,那会让 objectstack build 今天写出的一种 artifact 被拒。


Generated by Claude Code


Generated by Claude Code

…rd package body stages

Four stages, four declarations: authoring, in-memory assembled, on-disk
artifact, registry record. The last two had no declaration until now.

- packages/spec/src/automation/flow-function.zod.ts exports
  FlowFunctionLoweredDeclarationSchema, the serialisable half of the pair.
- packages/spec/src/stack.zod.ts declares ArtifactStagePackageBodySchema and
  RecordStagePackageBodySchema BESIDE AssembledPackageBodySchema, which is
  untouched (composeStacks keeps building bodies that hold live callables).
- packages/spec/src/api/package-api.zod.ts rebinds the installed-package row's
  manifest to the record stage; the z.unknown() override and the docblock
  defending it are gone.
- packages/objectql/src/registry.ts normalises a bare callable functions entry
  to the declared form at the assembly boundary, so the structural projection
  reports every declared function instead of dropping the bare ones.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…s, and fix the record-copy cast

- json-schema.manifest / authorable-surface / authorable-defaults gain
  automation/FlowFunctionLoweredDeclaration: the lowered record now publishes,
  which is what unemitted-schemas.baseline.json already claimed about it.
- dropped-refinements.baseline.json gains one site per installed-package
  response: the record stage DECLARES hooks where z.unknown() declared nothing,
  so HookSchema's `object` refinement now reaches the runtime and not the file.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…declaration's type aliases

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…sembled-package-body-inert-json

# Conflicts:
#	packages/spec/api-surface-declarations/api.txt
#	packages/spec/api-surface-declarations/automation.txt
#	packages/spec/api-surface-declarations/root.txt
#	packages/spec/api-surface-declarations/ui.txt
#	packages/spec/dropped-refinements.baseline.json
…dropped-refinements ledger

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

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 18 documentable anchor(s). ⚠️ 9 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/api-surface/root.json, packages/spec/authorable-defaults/automation.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 9 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/api-surface/root.json, packages/spec/authorable-defaults/automation.json, …) — pages documenting those are invisible to this run
  • 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 — 137 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 97f4f8c8282ebb78de0815cec24eddb8b8335eb0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 97f4f8c8282ebb78de0815cec24eddb8b8335eb0

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

…sembled-package-body-inert-json

One conflicted path, packages/spec/src/api/package-api.zod.ts, and the overlap is
the import block alone: main's side adds `retiredKey`, this branch rebinds the row
manifest's import from `AssembledPackageBodySchema` to `RecordStagePackageBodySchema`.
Both imports kept; the assembled-body import is dropped because nothing in the merged
file references that symbol in code any more (three prose mentions in the docblock only).

Measured: the merged file equals main's side plus this branch's exact delta, and equals
this branch's side plus main's exact delta -- added/removed line multisets identical in
both directions.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
Discharges the os-regen deferral taken on the merge commit. Step 2 restored
main's side of content/docs/references/api/package-api.mdx (both sides moved it,
so the driver had silently kept one); this regeneration re-derives this branch's
generated content on top of it, which is why the `functions` and `hooks` rows
read as the record-stage declaration again instead of `any`, while main's retired
`limit`/`cursor` query-parameter rows stay.

`pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up
to date.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
… side of

`scripts/pm/os-regen-merge.sh` was rerun once more after the regeneration commit
had landed. Its step 2 is not idempotent across that boundary: with the branch's
regenerated bytes in HEAD the "both sides changed it" test now fires for
content/docs/references/api/package-api.mdx and content/docs/references/index.mdx,
so it restored main's side of both and committed it -- erasing
`FlowFunctionLoweredDeclaration` from the automation listing (schema count
1533 -> 1532) and rolling the package-api `functions`/`hooks` rows back to `any`.

This commit re-runs `gen:schema && gen:docs` on the merged tree. The result is
byte-identical to the regeneration commit: `git diff 3a7ab9c -- content/` is
empty.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
Discharges the os-regen deferral the merge commit recorded. `origin/main`
and this branch both moved content/docs/references/index.mdx, so the driver
resolved it with exit 0 and silently kept one side -- measured: the merged
blob was byte-identical to this branch's, and main's side (the UI section's
`DashboardWidgetChartConfig` row and its counts) was gone.

Step 2 restored main's side into the working tree only, and this commit is
`gen:schema && gen:docs` re-derived on top of it. The result is the union of
both intents: the UI module reads 16 pages / 158 schemas with
`DashboardWidgetChartConfig` listed, the automation module reads 14 pages /
75 schemas with `FlowFunctionLoweredDeclaration` listed, and the total moves
1533 -> 1534. Against each side separately the regenerated file differs by
exactly the other side's delta and by nothing else.

No hand edit: the file's own header routes it to
`pnpm --filter @objectstack/spec gen:schema && gen:docs`.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: aac764cc36113b4e52820c1695715f000ccbe1b4

① Derived judgments

Governing ruling, established from the thread rather than the PR body. The card carries four ruling records. 5651572469 (batch #127 item 4, 2026-09-13, maintainer 「其他同意」) prescribed an artifact-stage body BESIDE the assembled one and the read-row rebind. 5716259259 (batch #149 item 1 letter B, 09-17) was presented after a pm:retriage (5706769218 / 5706819210) that did not cite #127, and prescribed the opposite: narrow the assembled body through entry-schema variants. Round R4 measured B as unexecutable (it refuses the in-repo fixture composeStacks builds today, contradicts the 「cannot drift」 invariant, and cannot meet its own acceptance because the inline array member keeps a z.function() handler) and delivered an empty diff by design (Release 5728544298). 5729478920 (batch #159 item 2 letter A, 09-18) restated #127 and WITHDREW B. Round R5 then stalled at A's own step 4: toRecordManifest leaves a residual that is neither a string nor a lowered record, and the producer is packages/objectql/src/registry.ts, outside the surface A drew — second empty diff (Release 5729976367). 5748934194 (batch #192 item 3 letter A′, 2026-09-20, maintainer 「192 同意」) is A plus the mechanism for step 4: a record-stage body with functions[].handler optional, the read rows rebound to THAT stage, and a bare-callable normalisation in objectql at the assembly boundary, ⛔ no minted ref, ⛔ no dropped entry. A′ governs the delivered diff, and the diff executes it step for step. The two places the PR body says it read the ruling rather than transcribed it are judged below.

What @objectstack/spec gains at this head (read from api-surface/, export-origins/, json-schema.manifest/, authorable-*/ and the source, not the PR body): root exports ArtifactStagePackageBodySchema, RecordStagePackageBodySchema and the four …Body / …BodyParsed type aliases; automation exports FlowFunctionLoweredDeclarationSchema with FlowFunctionLoweredDeclaration / …Parsed. json-schema.manifest/automation.json gains automation/FlowFunctionLoweredDeclaration; authorable-surface gains its handler and effect; authorable-defaults records effect = "pure"; the reference docs gain the section and index.mdx counts 1534. declaration-map/automation.json newly maps the AUTHORING FlowFunctionDeclarationSchema to automation/FlowFunctionLoweredDeclaration — that is the generator's documented base-pass rule (it follows the .extend() receiver of an exported def), not a hand edit, and it collides with nothing because the authoring declaration has no def of its own. Nothing exported is lost; the only removal is the module-private AssembledPackageRecordBodySchema const in package-api.zod.ts, which was never exported.

What changes. AssembledInstalledPackageSchema.manifest — the row both ListInstalledPackagesResponse and GetInstalledPackageResponse carry through InstalledPackageAtEitherStageSchema — rebinds from the assembled body with two z.unknown() holes to RecordStagePackageBodySchema. The published JSON Schema of the two responses therefore declares functions and hooks in full (the generated content/docs/references/api/package-api.mdx rows move from any to the lowered shapes). dropped-refinements.baseline.json gains four sites, all …manifest.hooks.element.object: that is HookSchema.object's pre-existing .refine() (hook.zod.ts:220) now reaching the runtime through a declared key while staying out of the JSON output — the ledger doing its job, not a weakened gate.

Is AssembledPackageBodySchema genuinely untouched? Yes, measured two ways. git diff 4b58dcf96..aac764cc3 -- packages/spec/src/stack.zod.ts is one widened import line plus a 142-line pure insertion between the AssembledPackageBodyParsed alias and ArtifactPackageSchema; AssembledPackageBodySchema (1283-1286), assembledPackageBodyShape (1167), composeStacks and the 「cannot drift」 invariant (3974) are byte-identical to the base. Both fenced files (data/hook.zod.ts, migrations/registry.ts) have an empty diff. Executed in an exported copy of this head (never in the clone): compose-stacks-manifest-preserve.test.ts + assembled-package-body.test.ts + the two new or changed spec test files — 4 files / 120 tests green. My own probe: the assembled body accepts a live callable in the map form, the array form and hooks[].handler, and both JSON stages refuse all three.

Do the two stage schemas admit what objectstack build and SchemaRegistry.installPackage really write? packages/cli/src/utils/lower-callables.ts:254-265 writes out[ref] = ref (a bare string) for a bare entry and { ...value, handler: ref } for a declared one; :188-204 lowers hooks[].handler to a string. Probe on the artifact stage: bare ref string accepted, lowered record accepted, callable refused, { effect } residual refused, empty-string ref refused. installPackage has exactly ONE record-writing site (registry.ts:4301, toRecordManifest(withDeclaredFunctionEntries(manifest))), so no update or upgrade path bypasses the normaliser. Probe on the record stage: { effect: 'writes' } accepted, {} accepted and parses to { effect: 'pure' }, array { name, effect? } and { name } accepted, string and lowered record accepted, callables refused. Hooks: a projected hook with neither handler nor body is accepted by all three stages because base HookSchema.handler is already .optional() (hook.zod.ts:252, deprecated in favour of body), so the PR's z.string().optional() inherits that optionality rather than adding it — ruling A′'s 「hooks[].handler a string」 is met. Strictness parity: a map entry with an unknown key is refused by all three stages (the lowered form derives from the strictObject declaration); an ARRAY entry with an unknown key is stripped by all three, because the authoring array member is plain z.object (stack.zod.ts:648) and the transcription matches it — no new tolerance in either direction.

OPEN QUESTION 1 — admitting both lowered spellings. Faithful, and I would have failed the other reading. The ruling's phrase 「functions entries the lowered declaration」 is shorthand for the lowered members of FlowFunctionEntrySchema, which are exactly two (flow-function.zod.ts:265-270: the bare ref string and FlowFunctionLoweredDeclarationSchema); ruling A's step 4, which A′ inherits verbatim, names the legitimate wire shapes as 「a string or a lowered record」. A stage admitting only the record form would refuse every artifact objectstack build writes for a bare function — the class of failure that withdrew B, one key across. Nothing in the tree supports the single-form reading.

Does the objectql change alter what GET /packages serves, and how? Yes, additively. withDeclaredFunctionEntries rewrites a bare callable map entry to { handler: fn, effect: DEFAULT_FLOW_FUNCTION_EFFECT } before the structural projection, which then leaves { effect: 'pure' } — the same value normalizeFlowFunctionEntry assigns a bare callable at boot (flow-function.zod.ts:324), so the record reports what the runtime actually does with the entry, not a minted value. Rows for packages with bare-callable entries gain those keys; nothing is removed; the showcase row goes 1 → 2. The projection's structural rule is untouched, no ref is minted, and the caller's manifest is copied only when an entry was rewritten. No in-repo consumer reads manifest.functions off a registry row (packages/runtime/src/app-plugin.ts:2282 reads the live bundle via resolveArtifactCollections; lit control manifest.objects hits). One asymmetry, not a defect: a bare entry lands as { effect: 'pure' } explicitly, while a declared entry authored without effect lands as {}; both sit inside the record stage, which defaults the latter on parse.

The two ruling items read rather than transcribed, my verdict on each. (a) unemitted-schemas.baseline.json is not edited: its reason line for Automation.FlowFunctionDeclarationSchema says the lowered record 「publishes normally」, which was false while the const was module-local and is true now that automation/FlowFunctionLoweredDeclaration is in the manifest — A′'s correction is achieved by making the sentence true. Acceptable. (b) The package-api.zod.ts docblock's last sentence is corrected in place, and the correction is true: scripts/build-schemas.ts imports the subpath namespaces (../src/kernel, ../src/ui, …) and never ../src/stack.zod, so those two members were never why ArtifactPackageSchema / ObjectStackDefinitionSchema publish no JSON Schema.

One class of row moves from tolerated to refused-by-declaration: a map entry whose effect is neither pure nor writes (reachable only from a defineStack({ strict: false }) host) now fails the record stage where z.unknown() accepted it. That is a producer defect the declaration exists to name, and packages/runtime/src/domains/packages.ts builds its response directly (deps.success({ packages: rows, … })) with no schema parse on the wire, so it changes what the contract says, not what the door does.

② Semver level

The written rule (AGENTS.md 1064-1068 at origin/main): a bug fix in a released package takes patch; the declaration is Clause-②: yes|no with at most one arm; yes takes at least minor; (narrowing) is BREAKING and must carry its FROM → TO migration when it removes or renames anything an author can write. Clause ② itself is defined in .claude/skills/pm-dispatch/references/lanes/spec.md:21-22: widening the accept set or expanding the public surface, however small, is Clause ②; narrowing is still contract surface but does not trigger it, and declaring yes on it is never an error. SKILL.md:515: pulling behaviour back to its declared contract does not touch it.

  • @objectstack/spec: minor — correct. Nine new permanently published exports expand the public surface, so Clause-②: yes and at least minor. The read-row narrowing withdraws nothing an author can write: the values it now refuses are a live callable in a JSON row and an invalid effect, neither of which a valid producer emits (measured above on the build output, the projection residual and the array form), so no (narrowing) arm and no migration are owed. This is also exactly the grade ruling 🔗 Broken links detected in documentation #127 item 6 assigned to 「the read API's two keys narrow from anything to their declared shape」.
  • @objectstack/objectql: patch — correct under the written rule; this is my reading of OPEN QUESTION 2. No export is added, removed or renamed; the payload gains entries the previous declaration (z.unknown()) already permitted, so no consumer could have relied on their absence; the record is being pulled back to what the package declared and to the record stage, which SKILL.md:515 says is not Clause ②. That is a bug fix in a released package, and the rule assigns it patch. The PR-level Clause-②: yes is satisfied by the spec changeset; the rule does not require every changeset in a yes PR to be minor. Grading it minor would not be an error under lanes/spec.md:22, but it is not what the rule asks for, and ruling A′ asked only that the payload change be declared in the objectql changeset, which it is.

③ Boundary flags

  • Published contract semantics moved (handled). The two installed-package read responses now DECLARE manifest.functions and manifest.hooks instead of accepting anything. Every row the door really serves still parses (measured on the build output, the projection residual, the array form and a projected hook), and the door has no runtime response parse, so no runtime refusal that did not exist before is introduced. The four new dropped-refinements sites are recorded by name.
  • GET /packages payload change (handled, declared in the objectql changeset). Additive; nothing that was on the wire leaves it.
  • Tier H surface, for the maintainer's hand. skills/objectstack-platform/references/_index.md moves one generated line (net 0 tokens, check:skill-refs green). Because the Exports: fallback lists the first five non-constant exports in source order (packages/spec/scripts/lib/export-list.ts, slice(0, 5)), the stack.zod.ts pointer row now names ArtifactStagePackageBodySchema and RecordStagePackageBodySchema and NO LONGER names ArtifactPackageSchema or ObjectStackDefinitionSchema. That is the generator's rule rather than this PR's editing and is not a reason to fail, but it is a visible change to a customer-facing skill index, and the two names that dropped are the file's most consequential exports. A module doc block on stack.zod.ts in a separate change would stop the row depending on export order.
  • No security or permission boundary, no migration or retirement shape, no gate weakened. AssembledPackageBodySchema, composeStacks, the drift invariant and both fenced files are untouched.

Measurement notes. The two new schemas carry the structural annotation z.ZodType(Record(string, unknown), Record(string, unknown)) — written here with parentheses in place of angle brackets — the same as the assembled body. Executed locally on an exported copy of this head: 4 spec test files / 120 tests, and a 25-row probe of the three stage schemas with lit and dark controls (z.string() converts, z.object({ a: z.function() }) does not; 55 / 55 / 55 shape members). NOT MEASURED locally: the @objectstack/objectql suite and the runtime conformance files — execution evidence for those is the 42 check runs on this sha (38 success, 4 skipped) read from the API — and the check:generated family, read from the diff and the same check runs.

Implemented-by: claude/issue-17518-assembled-package-body-inert-json
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

…t-json

Third merge round. main moved 128 commits past the merge base 4b58dcf.

Two paths needed a decision:

- content/docs/references/index.mdx and content/docs/references/api/package-api.mdx
  are merge=os-regen routed. The driver resolved both with exit 0 and silently kept
  THIS branch's side, dropping main's. Measured by blob hash against both sides, not
  assumed. main's side is restored into the working tree only; the regeneration
  commit that follows re-derives both from the merged sources.

- packages/spec/dropped-refinements.baseline.json is NOT driver-routed; it is a
  hand-edited, shrink-only ledger with no gen: script. Its only textual conflict was
  the three summary counters in the `measured` header; the `entries` body merged
  cleanly and holds the union of both sides (205 entries = 204 ours + main's
  api/DatasetSelection; main's repair of the fields.out.keyType sites kept). The two
  body-derived counters are recomputed here; refinementSitesThatDidProject is a
  measurement and is corrected in the regeneration commit.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
Discharges the os-regen deferral recorded by the merge commit. Both routed
paths are regenerated whole from the merged sources; no byte is hand-edited.

Union proof, both directions, per path:

  content/docs/references/api/package-api.mdx
    regen vs this branch's side == main's delta   (20 lines, identical)
    regen vs main's side        == this branch's  (14 lines, identical)

  content/docs/references/index.mdx
    regen vs this branch's side == main's delta   (12 lines, identical)
    regen vs main's side        == this branch's  (6 lines, identical)
    The two lines excluded from that comparison carry the running schema TOTAL,
    which a union must move where neither side alone does: base 1533, each side
    alone 1534, merged tree 1535 — and 1535 is what the generator itself reports
    for the merged sources, so the total is measured rather than reconciled.

  main brought   DatasetSelection/DatasetCompareTo/DatasetTotals into api/analytics
                 and retired KernelSecurityScanResult/KernelSecurityVulnerability
                 from kernel/plugin-security-advanced.
  this branch brought FlowFunctionLoweredDeclaration into automation/flow-function.
  Both survive in the merged output.

The ledger's last header counter is the build's own reading of the merged tree:
gen:schema measures 565 dropped sites across 205 published schemas, 366 projected,
9 with no JSON form — the first two already matched the union resolved in the merge
commit, so only refinementSitesThatDidProject moved.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 96dd3549ff68924bcc665fe3209542d44257a6c2

Scope: the DELTA between the previously PASSed head aac764cc361 (comment 5751097514) and this head — one merge of origin/main (7ba5f7f9e6f, 128 commits past merge base 4b58dcf96b3, both parents verified) and one regeneration commit (96dd3549ff6). The feature was reviewed before; this record judges what the merge did to it. Every reading below was taken from the tree, a fresh export of this head with its own dependency install, and the GitHub API — ⛔ not from the round's report, which I read afterwards to compare.

① Derived judgments

The PR's effective diff against main is the same 22 files, and on every both-touched file it is the same change the earlier PASS reviewed. git diff --name-only main..head and git diff --name-only base..aac764cc361 are the identical 22-path set (0 gained, 0 lost). Six of them were also edited by main: content/docs/references/api/package-api.mdx, content/docs/references/index.mdx, packages/spec/dropped-refinements.baseline.json, packages/spec/src/api/package-api.test.ts, packages/spec/src/api/package-api.zod.ts, packages/spec/src/stack.zod.ts. For each I ran a two-direction union proof on the added/removed line multisets — direction A: (head vs this branch's side) must equal (base vs main); direction B: (head vs main) must equal (base vs this branch's side). The three source files and package-api.mdx are IDENTICAL multisets in both directions. So the PR's change to stack.zod.ts against current main is still one widened import plus a pure 142-line insertion (+143/-1), the package-api.zod.ts rebind is still +29/-38 and the test file +64/-7 — byte-for-byte the changes the earlier record judged. Main's own edits to those files are orthogonal: #19595 rewrote the limit/cursor tombstone prose to name the enabled filter, #18319 repaired test fixtures to reverse-domain ids, and #19314 sits at stack.zod.ts line 3426 onward in the declaresCollection/composeStacks region, naming neither functions nor hooks. packages/objectql/src/registry.ts was not touched by main at all.

Finding ① — the second deferred os-regen path. Verified, and the drop was real: at the merge commit the package-api.mdx blob is 988bedaa480, identical to this branch's side and not main's d09cd420711; a grep of the merge-commit revision for main's two contributions (the reverse-domain id description, the enabled tombstone) reads 0 / 0. At this head the same greps read 8 / 2, this branch's declared functions rows read 2, and the dark control (any-typed functions rows) reads 0. The union proof is exact in both directions (+10/-10 and +7/-7). Independently, pnpm --filter @objectstack/spec check:generated on my export of this head exits 0 with check:docs over content/docs/references/** green, so the committed file is what the generator emits from the merged sources. The driver's own docblock (scripts/git-merge-regen.mjs lines 40-46) confirms the mechanism: defer, exit 0, record in os-regen-pending, and the content left behind is OURS by construction. Nothing main landed in that file was lost.

Finding ② — the hand-resolved ledger. Verified as a set operation across base, ours, theirs, merge commit and head. Keys: base 204, ours 204 (0 added, 0 removed), main 205 (api/DatasetSelection added, 0 removed), head 205 = the exact union (0 missing, 0 extra). Per-entry sites reconciled against the expected union (base minus each side's removals plus each side's additions): 0 mismatches over all 205 entries; the four entries this branch adds a manifest.hooks.element.object site to are also four of the entries main repaired, and head carries both edits on each. api/DatasetSelection is present at head and absent from this branch's side, so it arrived from main. Main's fields.out.keyType repair: NINE entries carried such a site at base (the round's narrative said five — a miscount in the prose, not in the file), main removed all nine, head has zero — the removal survived in full. Header: 205 / 565 match the body I counted; refinementSitesThatDidProject was 357 at the merge commit (this branch's stale value) and is 366 at head. I ran pnpm --filter @objectstack/spec gen:schema on my export: exit 0, and it prints 565 refinement site(s) across 205 published schema(s), 366 refinement site(s) DID reach the file, 9 had no JSON form — the four header numbers, re-derived by the instrument that owns them. That run is also the ledger's bidirectional validator (checkDroppedRefinements in scripts/lib/dropped-refinements.ts reports undeclared, miscounted with added/removed/length, repaired and vanished), so its exit 0 means the hand-merged entries equal the observed census path by path. Confirmed the round's design claim: the docblock at line 68 states the ledger is hand-edited with no gen: script by decision, no writer exists anywhere in the tree (git grep over scripts/, packages/spec/scripts/, packages/spec/package.json), and readDroppedRefinementsBaseline never reads measured. ⚠️ Consequence, stated rather than glossed: refinementSitesThatDidProject has no reader and no pin, so nothing in the repo would have caught 357 — the round's own out-of-scope finding is correct and I concur; it is pre-existing, not introduced here.

Finding ③ — the running total 1535. The reasoning holds: base 1533, each side alone 1534, and a union of two disjoint +1 changes must land on 1535, so the two total lines cannot satisfy a single-side-delta test and their disagreement is the signature of a correct union. Measured three independent ways rather than argued: (a) gen:schema on my export prints objectstack.json (1535 definitions); (b) packages/spec/json-schema.manifest/ — a set of sorted lists that git merged as a plain text union with no hand involved — sums to 1533 / 1534 / 1534 / 1535 at base / ours / main / head; (c) the per-module column of the index.mdx table at head sums to 1535 (API 444, Automation 75, Kernel 157, UI 158). On index.mdx the union proof is exact in both directions once those two lines are excluded (+8/-8, +5/-5), and the head lists DatasetSelection, DatasetCompareTo, DatasetTotals, DashboardWidgetChartConfig and FlowFunctionLoweredDeclaration once each while KernelSecurityScanResult and KernelSecurityVulnerability are absent; at the merge commit the control reads 0 DatasetSelection and 1 KernelSecurityScanResult, i.e. main's side really was missing there and is present now.

Both sides' load-bearing names, source AND built dist. Source and ledgers at head: ArtifactStagePackageBodySchema (3 src files, api-surface/root.json, export-origins/root.json), RecordStagePackageBodySchema (4, root, root), FlowFunctionLoweredDeclarationSchema (3, api-surface/automation.json, export-origins, json-schema.manifest/automation.json), DatasetSelectionSchema (5, api-surface/api.json, export-origins, manifest), KernelSecurityScanResultSchema / KernelSecurityVulnerabilitySchema 0 in all three ledgers — with the lit control that both WERE in api-surface/kernel.json and export-origins/kernel.json at the merge base, and the dark control ThisExportWasNeverAuthoredSchema 0 everywhere. Built dist: I built @objectstack/spec on my export (exit 0, 34/34 declaration files, 378/378 references resolved) and probed through the published export map from a workspace package that declares the dependency — 11/11 held: all six present names present in their entries, both retired names undefined, AssembledPackageBodySchema and KernelSecurityPolicySchema present as lit controls, the dark control undefined. The retirement's pin, packages/spec/src/kernel/plugin-security-scan-result-retirement.test.ts, asserts not.toHaveProperty for both retired names on the ./kernel barrel with six surviving neighbours as anti-vacuity, and the only remaining word-level mention of either …Schema name in packages/spec/src is that pin (the other mentions are the migration registry entries and prose). Main's retirement and its guard both survived.

Did the merge withdraw, weaken or alter anything the earlier PASS relied on? No, measured on the built dist of this head: 23/23 behaviour assertions held, with an instrument row (ManifestSchema accepts the fixture), lit and dark controls. The assembled body accepts a live callable and both JSON stages refuse it; both stages accept both lowered spellings; the record stage accepts a handler-less declaration and the artifact stage refuses it; {} parses on the record stage to effect: 'pure'; a string hook handler is accepted and a callable one refused; both JSON stages convert under z.toJSONSchema and the assembled body does not (z.string() lit, z.object({ a: z.function() }) dark); an unknown key is refused by all three. One thing I widened into because main's 128 commits include a BREAKING spec change: #18319 puts a reverse-domain rule on ManifestSchema.id. Both new stages are ManifestSchema.extend(...) derivations, the same construction as the assembled body, so the rule reaches Manifest / Assembled / Artifact / Record identically — measured false/false/false/false on NotReverseDomain, true on a conforming id (lit), false everywhere on an id with a space (dark). The merge did not produce a stage lagging main's change. unemitted-schemas.baseline.json was touched by neither side, so the reason line the earlier PASS relied on is unchanged.

CI on this sha: 42 check runs, 38 success, 4 skipped, 0 other.

② Semver level

The written rule (AGENTS.md, now at lines 1082-1097 on origin/main; the dispatch's 1055-1075 pointer predates main's 17 inserted lines) is byte-identical in that region between the merge base and main, so main brought no change to the grading itself. Because the PR's effective change on every source file is the identical multiset the earlier record graded, the readings stand at this head:

  • .changeset/17518-inert-json-package-body-stages.md — @objectstack/spec: minor, still correct. Nine new permanently published exports (present in the dist at this head); Clause-②: yes demands at least minor. No (narrowing) arm is owed by this PR: main's #18319 narrowing of manifest.id reaches the two read responses through ManifestSchema regardless of this PR (the row's manifest was already ManifestSchema.extend(...) with two unknown holes), and it ships under main's own BREAKING changeset with its FROM → TO text — this PR neither adds to nor conceals it.
  • .changeset/17518-registry-record-reports-every-function.md — @objectstack/objectql: patch, still correct. registry.ts is untouched by main, so the reasoning of the earlier record (a released read door pulled back to its declared contract, no export added or removed) is unchanged by the delta.

Both changeset files are byte-identical between aac764cc361 and this head, and the Check Changeset runs on this sha are green.

③ Boundary flags

  • Published contract semantics, handled and not this PR's own. Because the two JSON stages derive from ManifestSchema, the published JSON Schema of the two installed-package read responses now also carries main's reverse-domain id rule (#18319). It arrives by derivation, applies equally to the assembled body, and is declared in main's breaking changeset; nothing here to fix, but a maintainer reading the regenerated package-api.mdx will see both this PR's functions/hooks rows and main's id rows move in one diff.
  • Gate strength, pre-existing gap surfaced by this merge, handled. The ledger's refinementSitesThatDidProject counter sat at 357 in the merge commit while the tree measured 366, and no reader or pin would have caught it; it was corrected by hand from the generator's printed value and my own run confirms 366. No gate is weakened by this PR; the counter was never a gate. The round has filed it.
  • No security or permission boundary, no migration or retirement shape introduced by this delta (main's #15932 retirement arrives intact with its pin), no new runtime refusal from the merge: packages/runtime/src/domains/packages.ts still builds its response without a schema parse, and registry.ts is untouched by main.

Measurement notes. Type annotations are written here with PARENTHESES in place of angle brackets: the two new schemas carry z.ZodType(Record(string, unknown), Record(string, unknown)), the same annotation as the assembled body. Executed on a fresh export of this head (never in the clone): gen:schema exit 0, @objectstack/spec build exit 0, check:generated exit 0 (all 15 artifacts up to date against the dist I built), an 11-row dist export probe and a 23-row behaviour probe. NOT MEASURED locally: the @objectstack/spec, @objectstack/objectql and runtime test suites and both typechecks — execution evidence for those is the 42 check runs on this sha (38 success, 4 skipped) plus the round's reported counts, which I did not re-run; the driver-free mergeability of this head against main, for which I have GitHub's mergeable field only.

Implemented-by: claude/issue-17518-assembled-package-body-inert-json
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — carriers cleared, landing

Both clause-② carriers were removed by this seat at 2026-09-22T07:43Z, one limb then the other, three seconds apart — a clear, ⛔ not a strip. Each write went through label-write.mjs's four steps and read back:

Record consumed: comment 5772860458 on this PR — ## Contract review, Served-tier: CONTRACT_REVIEW_TIER, Head-sha: 96dd3549ff68924bcc665fe3209542d44257a6c2, VERDICT: PASS. It is a merge-delta review: the earlier PASS (5751097514) named aac764cc361, and the sixth merge moved the head, so a review naming the landing head was owed and taken.

Tier verified from the reviewing round's own transcript, ⛔ not from the dispatch parameter — counting the harness-stamped per-message served-model field:

probe count
"model":"claude-fable-5-1" (CONTRACT_REVIEW_TIER) 154
any claude-(opus|sonnet|haiku) id 0
total "model": occurrences 156

⚠️ The two unaccounted occurrences are named rather than rounded away: both are "model":{"description":… inside tool schema definitions, not served-model stamps. 154 of 154 stamps at tier; the arithmetic closes.

The record was adopted verbatim; ⛔ nothing in it was edited.

One number the review corrected, and this seat had propagated

The merge round's prose said main removed the fields.out.keyType sites from five entries. The review measured nine. Re-counted by this seat rather than taking either side:

ref fields.out.keyType site lines
merge base 4b58dcf96b3 9
head 96dd3549ff6 0
origin/main 0
lit control — "sites" keys at base 204
dark control 0

The FILE was always correct; only the narrative miscounted, and the PR body had inherited it. The body is corrected.

Landing state, all re-taken AFTER the last write

check reading
① review PASS naming the landing head ✓ 5772860458; --pair sees it as C6-RECORD
② --pair 19373 exit 4 — one adverse row, C9, see below. ⛔ No carrier row is adverse
③ check runs on 96dd3549ff6 49 total — 43 success, 6 skipped, 0 adverse, 0 pending
mergeability mergeable: true, mergeable_state: clean
governed surface exit 3, tier H(人合), one path: skills/objectstack-platform/references/_index.md

The Tier H gate is lifted, by the register's own terms. Two APPROVED reviews by os-zhuang stand (5262113029, 5262584525). AGENTS.md:273-275, read first-hand:

Tier H … lift only for an authorized APPROVED review by an account in GOVERNED_APPROVERS, on ANY commit and not dismissed; that word is spent once per PR — the OWNING seat then lands it, later pushes included

⇒ both approvals sitting on aac764cc361 (now the pre-merge commit) still count — 「on ANY commit」 — and the sixth merge's push does not re-close the gate — 「later pushes included」. ⛔ This seat submitted no review, under any account.

⚠️ C9 is carried openly into the landing, ⛔ not worked around

--pair cannot reach 0 on this card: two live claim comments by different authors with no Release: between them, the earlier holder being a dev subagent session that ended 2026-09-12 and cannot post. The protocol's two exits are both unavailable, the checker forbids anyone writing that line on a holder's behalf, and the conflict between that prohibition and SKILL.md:496 is filed as #19400. Recorded on the card at 5751042169 and again at 5751467355.

C9 is a claim-bookkeeping row. It is not a carrier row, and Tier H's lift is the approval rather than a --pair zero. This seat is landing on the maintainer's direct instruction — quoted verbatim in 5772269872 — with the row named here rather than left for someone to find.

Next: auto-merge armed SQUASH, then followed to MERGED on origin/main with a lit and a dark control.


Generated by Claude Code

@os-litant
os-litant added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit eea7ccc Sep 22, 2026
53 checks passed
@os-litant
os-litant deleted the claude/issue-17518-assembled-package-body-inert-json branch September 22, 2026 08:10
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nfig (the catchall site) (objectstack-ai#19419)

Fixes objectstack-ai#19151

Clause-②: yes (narrowing)

⚠️ **This diverges from the claim comment, deliberately and on the
record.** Claim 5751323556 declares `Clause-②: no`; the criterion as I
read it says `yes (narrowing)`. What lands here is a **refusal newly
added to a published parse surface** — the same act, on the same family,
that the sibling PR objectstack-ai#19147 declared `Clause-②: yes (narrowing)` in
`.changeset/17852-record-proto-key-preparse-guard.md`. Grading a sibling
site differently from its family is how a family stops being one, and of
the two possible errors, declaring `no` on a real narrowing is the one
that lets a contract narrowing land without contract review. The seat
owns the correction if it reads the criterion the other way; I write
this line once, here, and nowhere else.

---

## STEP ONE — the vendor line, re-read first-hand. It holds.

The card's premise arrived half second-hand, so this was the gate before
any edit.

**Which zod, and how it was resolved.** `packages/spec/package.json`
declares `"zod": "^4.4.3"`. Resolved from the package's own entry rather
than from the manifest text:

```
node -e "const {createRequire}=require('module');
         const r=createRequire('.../packages/spec/src/index.ts');
         const p=r.resolve('zod/package.json');
         console.log(p, require(p).version);"
=> /home/user/objectstack-issue-19151/node_modules/.pnpm/zod@4.4.3/node_modules/zod/package.json  4.4.3
```

`pnpm --filter @objectstack/spec why zod` reports **`Found 1 version of
zod`** — 4.4.3 — so the resolution is not one of two. (The lockfile does
carry a second, 4.6.1, reached only through the better-auth family;
`packages/spec` never sees it.)

**The line, at the cited coordinates.**
`node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/schemas.js`,
`handleCatchall` opens at 759 and the skip is at **767-769**, exactly as
reported:

```js
function handleCatchall(proms, input, payload, ctx, def, inst) {   // 759
  ...
  for (const key in input) {
    // skip __proto__ so it can't replace the result prototype via the      // 767
    // assignment setter on the plain {} we build into                      // 768
    if (key === "__proto__")                                                // 769
      continue;                                                             // 770
    if (keySet.has(key)) continue;
    ...
    const r = _catchall.run({ value: input[key], issues: [] }, ctx);
```

`grep -n '__proto__' v4/core/schemas.js` returns exactly two sites in
the file: **767-769** here, and **1496** in `$ZodRecord`'s open-key
branch — the one objectstack-ai#17852 measured and PR objectstack-ai#19147 guarded. One function
apart, same shape, and the `continue` sits above the schema that would
judge the key in both.

⇒ **`premise_still_valid: true`.** The card's quotation was not accepted
as evidence; it was reproduced.

## The defect, reproduced end to end on this tree

At `origin/main` `0870fb5418`, through the real exported schema:

```
input (JSON.parse) own enumerable keys : [ 'total', '__proto__', 'other' ]
AssignmentConfigSchema.safeParse        => success: true
parsed own keys                         : [ 'total', 'other' ]
```

with two lit controls in the same run: the identical config **without**
`__proto__` round-trips both keys (so the instrument can see keys at
all), and the same `__proto__` **inside** `assignments` is already
refused loudly by objectstack-ai#19147's record guard (so the instrument can see a
refusal). `JSON.parse` is what makes `__proto__` an own enumerable key;
an object literal's `{ __proto__: … }` sets the prototype and never
reaches either loop.

This lands on data an author wrote on purpose: the schema's own docblock
says its top-level keys may be flow variables, and the descriptor
declares `additionalProperties: true`.

## The fix

`refuseCatchallProtoKey` in
`packages/spec/src/shared/record-proto-key-guard.ts` — a **sibling** of
`refuseRecordProtoKey`, both now calling one private
`refuseProtoOwnKey`. Identical mechanism, identical refused name,
identical issue shape (`custom`, `path: ['__proto__']`).
`refuseRecordProtoKey`'s message bytes and behaviour are unchanged.

Why a second wrapper rather than a second call site of the first: the
refusal **names the parser that would otherwise drop the key**, and here
that is `.catchall()`, not `z.record()`. An author told their top-level
flow variable was dropped by "z.record()" would go looking at the
`assignments` map — a different slot, one level down, with a different
guard. A pin asserts the two messages name their own parser and not the
other's.

## Why a pre-parse guard — the two alternatives, eliminated by
measurement

1. **A key/catchall schema cannot see it.** The `continue` at 769 is
above `_catchall.run`, so no catchall — not even `z.never()`, whose
`unrecognized_keys` list is built inside the loop the `continue` already
left — ever receives the key. Same structural unreachability the record
guard's docblock records.
2. **Declaring `__proto__` in the object's own shape refuses every
config.** Measured: zod reads a declared key as `input["__proto__"]` and
tests presence as `"__proto__" in input`; on an ordinary object both
answer through the **inherited accessor**, so the value is
`Object.prototype` and the key is always "present". A plain config with
no `__proto__` authored came back `success: false` with an
`invalid_type` at `['__proto__']`. (It is also unwritable as an object
literal at all — `{ __proto__: schema }` sets the shape object's
prototype rather than adding a key, measured: the shape had one key,
`assignments`.)

That leaves the raw input, ahead of the parse.

## Why `__proto__` only — re-derived, not copied

The record guard refuses `__proto__` alone on the ground that
`constructor` and `prototype` reach the key schema unskipped. That
ground had to be re-established at this position, because it is a
different loop. Measured at the catchall, top level:

| authored top-level key | parse | key in the output |
|---|---|---|
| `constructor` | success | kept |
| `prototype` | success | kept |
| `toString` | success | kept |
| `__proto__` | success | **dropped** |

⇒ the reasoning transfers exactly, and for the same reason it was true
below: only `__proto__` is structurally unrepresentable. Everything else
round-trips, so refusing it here would be a narrowing no ruling ordered.
The `assignments` slot keeps its own guard; the two are different
parsers at different depths and neither covers the other.

## The pins, and their ablation

The sharpest pin asserts **behaviour**, through one `classify()` helper
that discriminates the three outcomes an authored key can meet —
`refused` / `silently-dropped` / `silently-kept`. A bare `success ===
false` would pass for a schema that refused every config; a bare key
check would pass for one that kept the key and reported success. Both
the defect and its over-correction are named, not assumed.

Beside them: an unguarded-object CONTROL that must stay
`silently-dropped` on this exact zod; a preservation row per
reserved-looking name; the previously-accepted shapes (empty config,
bare legacy config, the CEL envelope and its malformed counterpart); the
`assignments` guard and the array-form prescription still firing at
their own paths; and an invariance pin on the JSON projection.

**Ablation** (`scripts/ablation-replace.mjs`, anchor declared and hit
exactly once, mutation verified against the disk):

```
anchor  x1 -> x0 ; blob 50724ef -> 21c448025a16   (mutation landed)
result  Tests  6 failed | 88 passed (94)
restore blob after restore 50724ef == blob at HEAD 50724ef, `git diff HEAD` empty
```

Direction observed: **red**, as expected. The six that turn red are
exactly the six `objectstack-ai#19151` assertions. **The `objectstack-ai#17852` / `objectstack-ai#18847`
record-guard pins stay green under the same mutation** — which is the
pin that the two guards are independent, and that these six are not
riding on the other one's work. The fix was committed before the
ablation, so the restore leg points at a commit that really exists; the
subject resolves through the package's own `src` (a same-package
relative import), so no `dist` leg is involved and none is claimed.

## What else moved, and why

`packages/spec/dropped-refinements.baseline.json` — the guard wraps the
object in a `z.preprocess` pipe, so the `AssignmentValue` refinement the
JSON projection already dropped sits one segment deeper:
`assignments.out.valueType` becomes `out.assignments.out.valueType`.
**Same single site, same gap, no new one.** The build gate caught it and
printed the corrected entry verbatim; this is that entry.

Nothing else regenerated: `pnpm --filter @objectstack/spec
check:generated` reports **all 15 generated artifacts up to date**, and
the JSON projection is byte-identical to the pre-change baseline — same
`type`, same single `properties.assignments`, same `xExpression:
'value'` on the map value, same `additionalProperties`. The expression
ledger still derives `assignments.*` through
`getSchemalessNodeConfigJsonSchemas()`, because every spec walker
resolves a preprocess pipe to its OUT side (`pipeAuthorableSide`). All
four are pinned, not merely observed.

## Verification

Every exit code captured before any pipe.

| what | verdict |
|---|---|
| `pnpm --filter @objectstack/spec build && … check:generated` | exit 0
— all 15 artifacts current |
| `pnpm --filter @objectstack/spec test` | exit 0 — **504 files / 14754
tests** |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 (test layer
included) |
| service-automation reconciliation suites (8 files: form↔Zod ledger,
expression ledger, config parse/schemas/unknown-keys, assignment
envelope ×2, logic nodes) | exit 0 — 117 tests |
| `dispatch-gates --commands` then `--ran` | **81 derived, 81 run, 0
UNRUN** |
| `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 |

Three of the 81 answered **exit 3 — PREREQUISITE NOT MET, which is not a
red and not a pass**: `check-plugin-teardown-shape --self-test` (its
positive control is pinned to a commit outside this shallow clone),
`check:dual-build-cjs-loads` and `check:type-check-debt` (both read a
whole-repo `dist/` this box did not build within the foreground cap). CI
builds and runs all three.

Two families were derived from a base four commits behind `origin/main`
and are named rather than assumed: `check:merged-result` and
`check:issue-citations` were wired into `lint.yml` after this branch's
base. Both were run anyway — green, after the citation-spelling
correction described below.

Measured for the changeset's disposition: **zero** authored use of
`__proto__` as a top-level key on an `assignment` node config, across
this repo, `examples/` and the `objectui` sibling — against a **lit
control of 100 authored `assignment` node declarations in 26 files here
and 11 files there**. The census is a working-tree reading at
`1418799698`, not a history question; this clone is shallow (boundary
`ae8edd2c4f71d6f6fea5261e8284997f5546392f`) and no count here depends on
history.

## Acceptance notes — noted, not filed

1. **`scripts/check-issue-citations.mjs` cannot resolve a same-repo
citation written `objectstack#N`.** `buildBoard` builds its probe set as
`rows.filter((r) => !r.qualifier)`, so every **qualified** citation is
excluded from the probe — while `classifyCitation` treats
`objectstack#N` as naming this repo and resolves it against that same
board. The number is therefore never on the board and always reports
`allocated-but-absent`. Two-leg measurement on this diff, same six
citations, same run mode: with `objectstack#17852` → `board: probed (1
citations)`, `2 allocated-but-absent`, exit 2; with `objectstack-ai#17852` → `board:
probed (2 citations)`, `6 resolves`, exit 0. Independent control: `GET
/repos/objectstack-ai/issues/17852` answers **HTTP 200**
(state `closed`), so the number resolves and the gate's own transport
would have found it had it asked. This diff's added citations use the
bare spelling, which is this repo's documented form for its own issues
and what the gate's failure text itself prescribes. The gate is not
otherwise touched here.
2. **`treeifyError` / `error.format()` throw on any issue path
containing `__proto__`.** Reproduced first-hand against objectstack-ai#19147's landed
record guard on zod 4.4.3: both throw `TypeError: Cannot read properties
of undefined (reading 'push')` on the `['assignments','__proto__']`
path, while the same call on an ordinary refusal path succeeds (lit
control). This guard uses the same `path: ['__proto__']` shape as its
landed sibling, deliberately — it adds no new exposure class, and
changing the path shape for one of the two would create two dialects and
pre-empt a decision that belongs to whoever takes that question.
objectstack-ai#19151's body already records this connection; it is not this change's
subject and not its acceptance condition.
3. **Two open PRs also hold
`packages/spec/dropped-refinements.baseline.json`** — objectstack-ai#19373 and objectstack-ai#19335.
That file is deliberately **not** `merge=os-regen` (recomputing a
shrink-only ratchet can widen it), so whichever lands second reads the
conflict by hand. No open PR holds either source file this change edits;
lit control on the same scan, 18:25:35Z: 11 open PRs touch
`packages/spec/` at all and 1 touches `packages/spec/src/automation/`.

Not filed here: the PM files what is worth filing, per ruling A-narrow's
own instruction that a further site is its own card **when measured**.

## Scope

One site. ⛔ No sweep over the 398 records, ⛔ no re-opening of objectstack-ai#17852's
ruling, and objectstack-ai#19147's `assignments` guard is untouched — it is correct
and it is not this change's. objectstack-ai#18670, the JSON-Schema projection gap, is
not addressed here and stays open.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… a re-entrant rerun (objectstack-ai#19447)

Fixes objectstack-ai#19392

Clause-②: no

## The defect

`scripts/pm/os-regen-merge.sh` marks its record `done` at exactly one
place, after step 3's commit has
succeeded. Step 3's refusal is a **designed** outcome — step 3's commit
is an ordinary commit and the
`os-regen` pre-commit hook's `refuse-stale` path fires on it — and it
exits before that write, while its
own instruction finishes the commit *outside* the script. So the one
path that hands step 3 to the
operator was the one path on which the record could never be marked.

`rr_classify` then gated `rerun` on **containment** — `git merge-base
--is-ancestor RECORDED_BRANCH_TIP
HEAD` — which every later branch commit satisfies too. The next run
therefore re-entered `rerun`, pinned
the base back to the recorded pre-merge shas, redid step 2, and the
branch bytes it discarded were the
operator's own regeneration commit, made one commit earlier on this
script's own instruction. Step 3
committed that revert. Exit 0, nothing refused.

## What lands

**1. The hand-off path marks the record — `phase=handoff`.** Step 3's
refusal now writes the record
before it exits: step 2 IS discharged in the index at that instant, and
the commit is the operator's.
The refusal says so, and says in the same breath that re-running the
script would redo step 2 over
whatever that hand-off commits.

**2. `rerun` asks equality, not containment.** A new one-line predicate,
`rr_head_is_recorded_merge`,
asks whether HEAD IS the recorded merge — a merge commit whose first
parent is the recorded pre-merge
tip and whose second is the main tip that was merged. Branch commits
past that merge mean step 2 was
discharged by somebody, and the answer is a refusal, as `orphan` and
`stale` already do.

**3. A sixth classification, `advanced`, with its own refusal.** A
record still reading `pending` whose
branch has moved past the recorded merge is refused with the record
printed and the by-hand step 2
computed off the *recorded* base — the same register `rr_refuse_stale`
and `rr_refuse_orphan` use. After
this PR that state is reachable only for a record nothing ever marked (a
run killed after step 1, or one
written before the marking existed); it is the safety net, not the
ordinary path.

## The record's phase vocabulary

| phase | meaning | HEAD still AT the recorded merge | HEAD past it |
|:--|:--|:--|:--|
| `pending` | step 1 recorded, step 2 not discharged | `rerun`
(unchanged) | `advanced` — refused (new) |
| `handoff` | step 2 discharged in the index, commit owed to the
operator (new) | `rerun` — that index was dropped, so step 2 is owed
again | discharged: `settled` / `plain` |
| `done` | step 2 discharged and committed by the script | `settled` /
`plain` (unchanged) | `settled` / `plain` (unchanged) |

**Why `handoff` and not `done`** — the card offered either. Marking
`done` reads as discharged in *every*
tree, including the one where the operator drops the staged index (`git
reset --hard`) instead of
completing the commit. There step 2 is **not** discharged, and a `done`
record would report "already
discharged" and exit 0 over a side the driver dropped — the same class
of silent loss this card is about.
A distinct phase plus the equality gate answers both trees. One
consequence, stated because the card
assumed otherwise: in this design hole 1 is **not** independently
closing. The equality gate is what
closes repro 1; the marking is what makes the post-fix run exit 0 with
"already discharged" instead of
the `advanced` refusal.

## Measurements

Repro 1 rebuilt as a standalone fixture (the card's shape: real
merge-driver behaviour keyed per path —
CONFLICTS on the MIXED path, defers the other with exit 0 — plus a
`pre-commit` hook printing the two
lines the header quotes from the real one). Same fixture script, two
scripts under test: `origin/main`
`23f1de0` and this branch's head. `FLOWX` is the operator's
regeneration.

| step | `origin/main` 23f1de0 | this branch |
|:--|:--|:--|
| run 1 — the MIXED conflict | exit 1, record `phase=pending` | exit 1,
record `phase=pending` |
| operator resolves and commits the merge | — | — |
| run 2 — the rerun, step 3's commit refused by the hook | exit 1,
record still `phase=pending` | exit 1, record `phase=handoff` |
| operator does what that refusal says: clear the hook, regenerate, `git
add -A && git commit` | `FLOWX` in HEAD = 1 | `FLOWX` in HEAD = 1 |
| **run 3 — the card's run** | **exit 0**, 1 × `RERUN`, 1 × `TAKING
main's side`, **`FLOWX` = 0** | **exit 0**, 0 × `RERUN`, 0 × `TAKING
main's side`, 1 × "already discharged", **`FLOWX` = 1** |
| same tree, record forced back to `phase=pending` | n/a | **exit 1**,
the `advanced` refusal with the by-hand step 2, `FLOWX` = 1 |

Repro 2 and the card's control, also rebuilt standalone, run against
both scripts — **identical readings
before and after**, which is the intended no-regression result (this PR
does not touch the `plain` arm):

| leg | both scripts |
|:--|:--|
| repro 2 — main moves the routed path again after a regeneration commit
| exit 0, 0 × `RERUN`, 1 × `TAKING main's side`, `FLOWX` = 0 |
| control — main's next commit leaves the routed artifact alone | exit
0, 1 × `KEEPING the branch's bytes`, `FLOWX` = 1 |

The script's own self-test carries all of this as cases 11 and 11b: 122
cases before, **127 after, exit 0**
(five new composite cases, thirteen readings). Case 11b is the
discriminating mutation in the register
6b / 8b / 9b / 10b already use — it puts the containment gate back by
literal replacement and replays run
3 in a copy of case 11's tree one commit earlier: the run re-enters
`rerun`, takes main's side back and
step 3 commits the revert with exit 0, `FLOWX` = 0. The card, reproduced
on demand.

## Repro 2 is NOT addressed here — recorded as an open question

The card's cheaper half for repro 2 is "a distinct per-path notice
**and** a non-zero exit". The notice
stays inside this file's register; the non-zero exit does not, and the
two were offered as one package:

- **Real need, measured.** Repro 2 is step 2's *designed* both-sides
arm, and the script does not lie
about it — its notice already says step 4's regeneration re-derives the
content, and it does. The
measured incident behind this card (the round-7 instance on PR objectstack-ai#19373)
is repro 1, not repro 2.
- **Long-term soundness.** Every non-zero exit in this script today is a
REFUSAL that stops the sequence
before it completes. A completed run — merge committed, step 4 printed —
exiting non-zero is
indistinguishable, to anything reading only the code, from a refusal.
That is a third meaning for
exit 1 and a contract muddle; a "completed but owing" signal needs its
own code and a declared
  vocabulary, which is bigger than this card.
- **Making it harder for an AI to get wrong.** The measured hazard on
this very card is an operator or
agent who reacts to a non-zero by re-running the script — the loop this
PR closes. An ambiguous exit
code makes that *more* likely; a loud per-path notice is the register
the file already uses.
- **Not spreading scope at the startup stage.** No new exit-code
vocabulary for one arm of one step.

Secondary, and stated so the seat can weigh it: the PM's net-line budget
for this card (net ≤ +90 lines,
spent exactly) could not hold the notice half plus the fixture that
would pin it, so it is reported
rather than half-landed.

## Acceptance notes

- Net `+92 / -2` = **+90** lines in `scripts/pm/os-regen-merge.sh`,
exactly the dispatch's figure. The
last ten lines were paid for by compressing prose this PR itself added
(no pre-existing line was
  re-wrapped to buy room).
- Scope held to the claim's file surface: one file, no merge driver, no
ledger, no workflow.
- `skip-changeset`: nothing under any package's `files[]` moves —
`scripts/pm/**` is on AGENTS.md's fast
  lane and publishes nothing.
- noted, not filed — the self-test in this file has neither of the two
shapes AGENTS.md's
"Writing a `--self-test`" section requires (a pinned battery-name floor,
and a module-level handshake
flag the dispatch refuses on). It counts `st_fail` and returns. That is
pre-existing, orthogonal to
this card, and out of its net-line budget. Successor: whoever next
touches this file's self-test, or a
  sweep driven by `scripts/measure-self-test-floor.mjs`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s from its displayed scale (objectstack-ai#19442)

Fixes objectstack-ai#19320

Clause-②: yes (widening)

⭐ Declared from the **measurement**, ⛔ not from the shape of the change:
the accept-set delta over 1950 cells is **36 ADDED / 0 REMOVED**, so the
narrowing arm is empty. ⚠️ The seat rewrote this line from a backticked,
prose-trailing spelling that the repo’s own `readClause2Line` reads as
`{kind: near-miss, reason: describing}` — a near-miss is ⛔ not a
declaration, and `Check Changeset`’s level axis would have had no input
from this body.

✅ **Both halves of the ruling are now here.** The behaviour half (the
validator's percent arm) and the `packages/spec` docblock half landed in
the same branch; the docblock half needed a file outside the boundary
this card was dispatched with, was reported rather than taken, and was
then authorized by the dispatching seat. The closing keyword is
therefore a closing keyword.

## The ruling this makes live

Maintainer ruling batch objectstack-ai#161 item 3 letter B (objectui#9810, comment
`5729749935`, 「其他同意」 2026-09-18T12:07Z). Quoted, not translated:

> - `packages/spec` `FieldSchema.scale` docblock (and the field
reference page): for `percent`, `scale` is the number of decimal places
of the percentage-point value as displayed and entered; stored precision
follows the storage scale (`fraction` ⇒ `scale + 2` places; `whole` ⇒
`scale`).
> - `record-validator.ts` `max_scale` branch: when `def.type ===
'percent'` and `percentScaleOf(def) === 'fraction'`, compare against
`def.scale + 2`; a pin per storage scale (fraction `scale: 2` accepts
`0.1234`, refuses `0.12345`; whole `scale: 2` unchanged).

## Premise re-verification — first-hand, on today's tip, with a lit
control

The card's premise was second-hand. Both halves were re-measured against
`origin/main` at base `24162f95`, through the **real** record validator
imported from the built `dist` of `@objectstack/objectql` — no harness,
no source shortcut.

| case | ruling B requires | measured BEFORE this PR |
| --- | --- | --- |
| fraction `scale: 2`, write `0.1234` (scale+2 places) | ACCEPT |
**REFUSE** `max_scale` `{scale:2, actual:4}` |
| fraction `scale: 2`, write `0.123` (scale+1) | ACCEPT | **REFUSE**
`max_scale` `{scale:2, actual:3}` |
| fraction `scale: 3`, write `0.33333` (the ruling's 33.333%) | ACCEPT |
**REFUSE** `max_scale` `{scale:3, actual:5}` |
| fraction `scale: 0`, write `0.33` | ACCEPT | **REFUSE** `max_scale`
`{scale:0, actual:2}` |
| fraction `scale: 2`, write `0.12345` (scale+3) | REFUSE | REFUSE
(agrees) |
| whole `max: 100, scale: 2`, write `12.34` / `12.345` | ACCEPT / REFUSE
| ACCEPT / REFUSE (agrees) |

**Lit control, same instrument, same run** — so the refusals above are a
reading and not a dead instrument: the validator ACCEPTED `0.12` and
`0.5` on the same field, and REFUSED for three *different* reasons —
`max_value` on `max: 1` with `5`, `min_value` on `min: 0` with `-0.5`,
and `invalid_number` on `'abc'`. `number` / `currency` / `slider` all
refused at `scale + 1` in the same run.

Docblock half, read at source: `FieldSchema.scale`'s `.describe()`
(`packages/spec/src/data/field.zod.ts`) states the 0-100 platform
ceiling and nothing about `percent`; `percentScaleOf`'s docblock
(`packages/spec/src/data/percent-scale.ts`) states the fraction/whole
rule and says nothing about `scale`. **Both halves of the card hold. The
premise is TRUE.**

## Accept-set delta — measured in BOTH directions

Both predicates (current and ruled) were run over an exhaustive corpus
of 1,950 cells: 5 numeric field types x 5 `max` declarations x 6 `scale`
values x 13 decimal-place counts.

```
corpus cells: 1950   unchanged: 1914   ADDED (accept set grows): 36   REMOVED (accept set shrinks): 0
declaration classes whose allowance moves: percent max=undefined, percent max=0.5, percent max=1
```

- The narrowing arm is **empty** — 0 of 1,950 cells. Nothing that writes
today stops writing; no stored value is re-read; no migration is
implied.
- Only fraction-stored `percent` moves. `percent` with `max` above 1,
and `number` / `currency` / `slider` / `rating` at every `max`, are
byte-identical in verdict.
- ⇒ `Clause-②: yes (widening)` is what the measurement supports. It was
dispatched as a claim to check; the claim survives the check.

⚠️ **One flag for the contract review, not a re-adjudication.** The
ruling's own Execution section declares `Clause-②: no`. The mechanical
criterion in `pm-dispatch` is 「本卡放宽接受集或扩大公开面吗」, and the accept set is
measurably relaxed, so the conservative routing arm is `yes`. The
declaration is by design provisional (「按设计临时…⛔ 非终审」), so this is a
routing difference to be recorded at review, not a change to the ruling.

## What this PR implements

`packages/objectql/src/validation/record-validator.ts` — the `max_scale`
branch gains its percent arm. The fraction/whole split is **read from
the spec's `percentScaleOf`**, not re-derived from `max` at this seam,
so the edit widget, the analytics wire and the validator keep answering
from one source.

The refusal envelope now reports the allowance that was **applied**: on
a fraction-stored `scale: 2` field, `0.12345` is still refused and
reports `constraint: { scale: 4, actual: 5 }`. Reporting the raw
declaration beside a stored fraction's place count would render "must
have at most 2 decimal places (got 5)" on a field that accepts four — a
true refusal described by a false constraint. This is the one detail the
ruling's letter leaves open; it is decided in the direction that keeps
the machine-readable surface honest, and it is pinned.

## The spec half — and the boundary that gated it

The ruling's first bullet is the `packages/spec` `FieldSchema.scale`
docblock **and the field reference page it generates**. That is
`packages/spec/src/data/field.zod.ts`, which was **outside** the
four-file boundary this card was dispatched with, so it was reported
before being touched rather than taken quietly. The two `packages/spec`
paths the dispatch originally named (`numeric-column-representation.ts`
and its test) are about the **DDL column** (`numeric_precision` /
`numeric_scale`) and mention `percentScaleOf` only inside a prose
comment — they are not part of this repair and are untouched.

What landed, after the seat authorized the corrected surface:

- `packages/spec/src/data/field.zod.ts` — `FieldSchema.scale`'s
`.describe()` now states **both** meanings: what the number counts on a
`percent` field (decimal places of the displayed percentage-point value)
and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒
`scale`, every other numeric type ⇒ `scale`). Both halves go in the
**describe**, not only in a source comment, because the reference page
is generated from the describe and an author who reads only that page is
the author the ruling is about.
- `content/docs/references/data/field.mdx`, `data/object.mdx`,
`system/migration.mdx` — regenerated by `pnpm --filter @objectstack/spec
gen:schema && gen:docs`, ⛔ never hand-edited. **Exactly those three
tracked files moved and nothing else**, which is what the pre-commit
source/regeneration split was arranged to make legible: the source edits
were committed first, so the regeneration commit's file list is the
regeneration's own output.
- `packages/spec/src/data/percent-scale.ts` — the optional
cross-reference, **taken**. The card's own measurement table named
*this* docblock as the one silent about `scale`, and `percentScaleOf` is
the function the validator calls, so a reader who lands here should find
the consequence rather than re-derive it. Written as a pointer, ⛔ not a
second copy: the rule is stated once on `FieldSchema.scale` and enforced
once in the validator.

**Serial constraint re-measured for those paths** before any of it was
written, same instrument as the dispatch used: 20 open PRs, `GET
/pulls/N/files` each, **0 unreadable file lists**, and no open PR
holding any of the eight paths. Lit control on the same run:
`packages/spec/src/**` matches 7 open PRs (objectstack-ai#19398, objectstack-ai#19374, objectstack-ai#19373,
objectstack-ai#19335, objectstack-ai#19314, objectstack-ai#19090, objectstack-ai#18319), so the zeros are absences the
instrument could see.

## Verification

**Ablation** — `scripts/ablation-replace.mjs`, anchor `? def.scale + 2`
in the production file.

- ⚠️ The **first attempt was a no-op and its reading is void**: the
replacement string was a prefix of the anchor, so its occurrence count
could not rise, and the tool refused before running anything. Recorded
rather than quietly retried.
- Second attempt landed: anchor `x1 -> x0`, blob `2e2d3981d29a ->
7b082941b44f`, command executed, restore proved `blob == HEAD
(2e2d398)` with `git diff HEAD` empty.
- Under ablation: **8 failed / 100 passed (108)**. All 8 are in the new
block and fail for the right reason — the fraction-stored writes are
refused with `max_scale`, and the envelope/message read `2` where the
ruling requires the applied `4`.
- ⭐ **5 of the 13 new cases cannot discriminate, and are not counted as
evidence**: the scale+3 refusal, the whole-percent pin, the
other-numeric-types controls, the other-refusal-reasons control and the
no-declared-scale control pass on both trees **by design** — they are
anti-vacuity and lit-control pins, there so that a branch which simply
stopped enforcing `scale` on percent fails this block too.

**Gate family** — derived from the real changed paths with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, every command run with its exit code
captured before any pipe, reconciled with `--ran` carrying the recorded
codes:

```
111 derived · 111 run · 107 green · 0 red · 4 NOT MEASURED · 0 unrun
```

- One real red was found and repaired in this PR:
`check:error-code-casing` read the envelope pin's bare `code:
'max_scale'` as an ADR-0112 D1 emission because its recognizer window
held no field-addressed neighbour. Naming the field is the repair and a
stronger assertion. Re-run exit 0: "no unlisted lowercase error codes in
6371 scanned file(s)".
- NOT MEASURED, each with its reason, none of them a red:
- `check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: reads
built output, 66 packages have no `dist/` in this worktree.
- `check:type-check-debt` — exit 3, PREREQUISITE NOT MET: needs the
whole workspace closure built.
- `check-plugin-teardown-shape.mjs --self-test` — exit 3: its
positive-control fixture is pinned to a commit this shallow clone cannot
reach.
- `check-engine-split-ratio.mjs --days 90` — exit 2: refuses to compute
an ADR-0076 D7 ratio on a shallow clone whose oldest visible commit sits
inside the window. Counted as "run" by the reconciler (exit 2 is not the
prerequisite code) but it measured nothing, so it is reported here as
NOT MEASURED.
- One gate **refused its prerequisite while spelling it `exit 1`**:
`check:skill-examples` reported that `packages/client-react/dist` held
no declarations, which is a refusal and ⛔ not a finding. Rather than
report it as NOT MEASURED, the package closure was built and the gate
re-run to a real verdict: exit 0, **258 prose examples type-check across
3 surfaces**, including the 10 spec-source TSDoc blocks — the surface
this PR edits.
- The two remaining `exit 3` families were **deliberately left
unmeasured**: both need a whole-workspace build, and both are whole-tree
families unrelated to a describe string and a validator arm. CI builds
everything and measures them there. The `check:skill-examples` closure
was built because that gate reads the spec source surface this diff
touches — the choice is principled, ⛔ not a budget.
- The reconciler's own verdict, DERIVED from the recorded codes rather
than claimed: `111 derived famil(ies) accounted for — 108 run, 3
NOT-MEASURED (3 DERIVED from a recorded exit 3)`.

**Suites and lint**, at the final commit:

- `pnpm --filter @objectstack/objectql test` — **303 files / 5050 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec test` — **505 files / 14,752 tests
passed**, exit 0; `pnpm --filter @objectstack/spec typecheck` exit 0.
- `pnpm --filter @objectstack/objectql typecheck` — exit 0;
`check:test-typecheck` OK, ledger unchanged at 40 files / 234 errors /
65 pinned signatures.
- `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 15
generated artifacts up to date** against the edited `FieldSchema.scale`.
Both gates the ruling's docblock half puts at risk are green by name:
**`check:docs`** (the three regenerated reference pages) and
**`check:authorable-surface`** (authorable surface + JSON schemas).
`authorable-surface.base.json` did not move — a regular build never
writes it.
- `pnpm lint` — repo-wide `eslint . --no-inline-config`, exit 0 at
`bb9f9274`, the final commit. The full union ran; no narrowing was
needed, so no narrowing is claimed.
- **The ablation reading still describes the shipped file**: `git
hash-object packages/objectql/src/validation/record-validator.ts` is
`2e2d3981d29a…`, byte-identical to the blob the ablation restored to, so
nothing landed on the production file after it was proved able to fail.

**Import-side pins**: the public surface of `@objectstack/objectql` is
byte-unchanged (no export added, removed or retyped), so only behaviour
could move a consumer pin. Every non-`objectql` test file mentioning
`'percent'` was checked for a co-occurring `scale`; the six hits are
`packages/spec` schema tests and two `service-analytics` wire tests,
none of which exercises the record validator. The grep returning six
files is its own lit control.

## Acceptance notes

Noted, not filed — neither meets the three filing classes, and the
carrier for each is named:

- The `max_scale` message template
(`packages/spec/src/system/validation-message.ts`) reads "must have at
most N decimal places", which on a fraction-stored percent now describes
the STORED fraction rather than the number the author typed. It is
accurate and it is not what the author sees in the widget. Whether a
percent-specific sentence is wanted is a display decision that belongs
with the ruling's author, not a defect. Carrier: the contract review on
this PR.
- `packages/spec/src/data/numeric-column-representation.ts` already
carries an accurate prose account of the fraction storage rule in its
`percent` entry. It is documentation of the column, not of `scale`, and
needs no change under this ruling. Carrier: none needed — recorded so
the next reader does not re-derive the same dead end.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rence three exported constants (objectstack-ai#19478)

Part of objectstack-ai#18697 — construction round **R1 of 2 (phase 1 only)**. This
round does not close the card; R2 does.

Branch: `claude/issue-18697-version-grammar-canon-phase1`

Clause-②: yes (widening)
Reason: the criterion has two limbs and this round hits the second one.
The ACCEPT SET does not move — every regex written here is
byte-identical to the literal it replaces, verified per carrier by
sha256 over the extracted literal (G1 `0dc7272048754554`, G2
`f0503f4f0c703a40`, G3 `3e3cb2710d62909a`, each hash equal across its
whole group and equal to the constant), so nothing an author can write
changes. But the PUBLIC SURFACE grows: three new exported constants on
`@objectstack/spec/kernel`, landing as three rows in
`packages/spec/api-surface/kernel.json` at :157, :381 and :382. A new
exported symbol is `yes` on its own, and that second limb is what this
declares. The ruling's `Clause-②: yes` therefore already holds for R1;
R2 carries its own accept-set widening on top of it.

## What this does

Eight in-repo carriers of "the version of a package or plugin" each
spelled a version regex out as a literal of their own. Three accept sets
written eight times, still growing on their own: three of the eight were
published schema declarations with no parse caller at all. A ninth
carrier of the same concept, `PackageManifestSchema.version`, spelled no
regex at all — it is a bare `z.string()` and is deliberately left
unconstrained by this round. Each now references the constant carrying
the pattern it already enforced.

`@objectstack/spec/kernel` gains three exported patterns
(`packages/spec/src/kernel/version-grammar.ts`, a new file):

| constant | grammar |
|:--|:--|
| `MAJOR_MINOR_PATCH_VERSION_PATTERN` | three numeric segments and
nothing else |
| `SEMVER_SHAPED_VERSION_PATTERN` | plus an optional prerelease and an
optional build suffix, identifiers in either ASCII case |
| `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` | the same, suffix
identifiers restricted to lowercase ASCII |

10 carriers → 3 referenced declarations + 1 deliberately unconstrained.

## Per-carrier before → after, with the byte-identity assertion

Every "before" literal was extracted from the file BY LINE WITH A
REQUIRED MARKER on that line (a moved line throws rather than reading
the wrong thing) and hashed. Within each group every hash is equal, and
the constant's own literal hashes to the same value.

**G1 — `MAJOR_MINOR_PATCH_VERSION_PATTERN`, sha256/16
`0dc7272048754554`, 17 bytes**

```
/^\d+\.\d+\.\d+$/
```

| carrier | line at `fbc12be3` | line on this head | after |
|:--|:--|:--|:--|
| `packages/spec/src/kernel/manifest.zod.ts` `ManifestSchema.version` |
414 | **415** | `z.string().regex(MAJOR_MINOR_PATCH_VERSION_PATTERN)` |
| `packages/spec/src/kernel/metadata-plugin.zod.ts`
`MetadataPluginManifestSchema.version` | 649 | **650** | same |
| `packages/spec/src/kernel/plugin-registry.zod.ts`
`PluginRegistryEntrySchema.version` | 158 | **159** | same |
| `packages/spec/src/kernel/plugin-validator.zod.ts`
`PluginMetadataSchema.version` | 144 | **145** | same |
| `packages/runtime/src/domains/packages.ts` `PATCH
/api/v1/packages/:id` | 1680 | **1770** |
`!MAJOR_MINOR_PATCH_VERSION_PATTERN.test(patch.version)` |

**G2 — `SEMVER_SHAPED_VERSION_PATTERN`, sha256/16 `f0503f4f0c703a40`, 54
bytes**

```
/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/
```

| carrier | line at `fbc12be3` | line on this head | after |
|:--|:--|:--|:--|
| `packages/spec/src/kernel/plugin.zod.ts` `PluginSchema.version` | 212
| **216** | `z.string().regex(SEMVER_SHAPED_VERSION_PATTERN)` |
| `packages/core/src/plugin-loader.ts` `isSemverShapedVersion` | 501 |
**505** | `return SEMVER_SHAPED_VERSION_PATTERN.test(version)` |

**G3 — `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN`, sha256/16
`3e3cb2710d62909a`, 48 bytes**

```
/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?(\+[a-z0-9.-]+)?$/
```

| carrier | line at `fbc12be3` | line on this head | after |
|:--|:--|:--|:--|
| `packages/spec/src/marketplace/package-version.zod.ts`
`PackageVersionSchema.version` | 146 | **147** |
`.regex(SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN)` |

**G4 — untouched, on purpose.**
`packages/spec/src/marketplace/package-version.zod.ts:81` on this head
(`:80` at `fbc12be3`) — `PackageManifestSchema.version` — keeps its bare
`z.string()`. Giving it a grammar here would be an accept-set move and
would break this round's premise.

**Tenth carrier, out of reach from here.** `objectui`
`PackageFormDialog.tsx:39` / `:227` is a second repository and gets its
own card at this PR's ACCEPT.

## Why the fences held

- **No `.describe()` text, refusal string or JSON Schema `pattern`
moved.** The constant is the regex only; each carrier keeps its own
description. Moving the prose is phase 2's job, because the prose only
becomes true when the grammar moves.
- **Nothing was exported from the ROOT entry.** The constants are on
`./kernel`; `marketplace/package-version.zod.ts` references
`SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` cross-entry via
`../kernel/version-grammar`, the precedent at
`marketplace/marketplace.zod.ts:4`.
`packages/spec/api-surface/root.json` is untouched, so PR objectstack-ai#19373's hold
on that file is intact.
- **No `packages/spec/api-surface/*.json` was hand-edited**, and
`packages/spec/CHANGELOG.md` and `packages/spec/package.json` were never
opened.
- **The objectstack-ai#16365 record in `plugin.zod.ts` is intact.** One sentence in it
became false — the one saying this key's regex is
`PluginLoader.isSemverShapedVersion`'s spelling character for character
— and it is corrected minimally, in place: the two now reference one
declaration and can no longer drift apart. The same correction is made
to that method's own docblock in `plugin-loader.ts`, where "Change one
spelling and you must change both" no longer describes anything. Nothing
about phase 2 is announced in either.

## Regenerated artifacts — gains only, on the allow-listed shards

`pnpm --filter @objectstack/spec check:generated` named exactly two
stale artifacts out of fifteen; both were regenerated with their own
generators, never by hand.

```
 M packages/spec/api-surface/kernel.json      (+3, -0)
 M packages/spec/export-origins/kernel.json   (+3, -0)
```

That is the whole of `git status` after regeneration.
`api-surface/root.json`, every `automation.json`,
`dropped-refinements.baseline.json`, `declaration-map/*` and
`json-schema.manifest/*` all came back byte-unchanged — which is itself
the neutrality reading: a pure literal-to-constant collapse produces the
identical Zod schema, so a moved `dropped-refinements` line would have
been evidence the refactor was not accept-set neutral.
`check:authorable-surface` and `check:declaration-map` pass with no
regeneration at all.

The six added rows are the three constant names in the two kernel
shards, and nothing is removed.

## The neutrality proof, and the instrument R2 will move

**Not one existing test expectation was edited.**
`packages/spec/src/kernel/plugin.test.ts` (which pins eight
forbidden-by-SemVer forms as ACCEPTED),
`packages/spec/src/kernel/manifest.test.ts` and
`packages/core/src/plugin-loader.test.ts` all pass unchanged — that is
the proof this cannot move a verdict.

New: `packages/spec/src/kernel/version-grammar.test.ts`, describe block
**`version grammars — accept-set pin`** — a 3 × 12 matrix pinning each
constant's verdict on `1.2.3`, `2.0.0-beta.1`, `1.0.0-Beta.1`,
`1.0.0+20230101`, `01.1.1`, `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0+.`,
`v1.0.0`, `1.0`, `latest` and the empty string, plus a source-bytes pin
per constant, a no-stateful-flag assertion, and the strict-containment
ordering (G1 inside G3 inside G2).

**Two ablations, each mutated through an anchor that had to hit, each
proved on disk, each restored byte-identically (`blob == HEAD`, `git
diff HEAD` empty):**

1. Widening `MAJOR_MINOR_PATCH_VERSION_PATTERN` by an optional `-beta`
group turns `manifest.test.ts > ManifestSchema > Basic Properties >
should enforce semantic versioning` **red** (1 failed / 65 passed). That
is the carrier wiring proved live: `ManifestSchema.version` really reads
the constant.
2. Widening it by an optional `-ABLATED` group turns the new pin's
**source-bytes** assertion red while the 3 × 12 matrix stays green — the
witness strings do not cover that shape. Reported because it is the
honest reading of what each half of the pin buys: the matrix pins
verdicts, the bytes pin catches an equivalent-looking rewrite the
witnesses cannot see. Both halves earn their place.

Cross-package wiring is visible in the built output too:
`packages/core/dist/index.js` and `packages/runtime/dist/index.js` both
reference the imported constant.

## Verification

| what | command | result |
|:--|:--|:--|
| build closure | `turbo run build --filter=@objectstack/runtime...
--filter=@objectstack/core... --filter=@objectstack/spec...
--concurrency=2` | 30/30 tasks successful |
| spec suite | `pnpm --filter @objectstack/spec test` | 507 files, 14837
tests passed |
| core suite | `pnpm --filter @objectstack/core test` | 51 files, 1321
tests passed |
| runtime suite | `pnpm --filter @objectstack/runtime test` | 271 files,
3761 passed / 1 skipped |
| typecheck | `turbo run typecheck` for spec, core, runtime | 32/32
tasks successful |
| derived gates | `node scripts/pm/dispatch-gates.mjs --commands` then
`--ran` | **89 derived, 86 run green, 3 NOT MEASURED, 0 UNRUN** |

The three NOT MEASURED are `check:dual-build-cjs-loads`, `check:i18n`
and `check:type-check-debt`, each exiting **3 — PREREQUISITE NOT MET**,
all three for the same reason: they read built output for the whole
workspace and only the spec/core/runtime closure was built here. Neither
red nor green; nothing was measured. CI builds the full closure and will
answer them. A fourth, `check-plugin-teardown-shape.mjs --self-test`,
also exited 3 on first run — its positive-control fixture is pinned to a
commit this shallow clone could not reach; after `git fetch --depth=200`
of that object it re-ran at **exit 0, 48 cases pass**.

## Enqueue: C5 fired, it was correct, and the declaration was corrected

Measured, not predicted:

```
node scripts/pm/check-widening-tells.mjs --declaration no --diff PR.DIFF   ->  exit 4

✗ T3 packages/spec/api-surface/kernel.json:157 — a new row in a published entry point's export listing
✗ T3 packages/spec/api-surface/kernel.json:381 — a new row in a published entry point's export listing
✗ T3 packages/spec/api-surface/kernel.json:382 — a new row in a published entry point's export listing
```

This PR was first pushed declaring `Clause-②: no`, on the reading that
an accept-set-neutral collapse is not a clause-② change. That reading
answered one conjunct of two. T3 was right: the public surface really
does grow by three exports, and the lane's criterion is
「放宽接受集**或扩大公开面**的卡,不论多小,即条款②」 — a new exported symbol is `yes` on its
own, whatever the accept set does.

The author escalated rather than flipping the declaration, and the
`domain:spec` seat re-declared `Clause-②: yes (widening)` in correction
comment 5754511685, superseding its earlier correction. **The question
is closed.** The instrument was not weakened and no card was filed
against it — the literals this diff removed were inline regexes inside
schemas, never on the public surface, so T3 measured exactly what it is
defined to measure. The accept-set half of the claim is untouched by any
of this and is still proved above, byte for byte.

## Contract review of record (row C6)

`domain:spec` owes an at-tier contract review on **every** round it
delivers, `Clause-②: yes` **or** `no`, and until that record exists this
PR is not landable. The record is written by an isolated
`CONTRACT_REVIEW_TIER` reviewer, not by the author. Its
`Implemented-by:` names
`claude/issue-18697-version-grammar-canon-phase1`.

## Changeset

`.changeset/18697-version-grammar-canon-phase1.md` — `@objectstack/spec:
minor` (new exported public constants), `@objectstack/core: patch`,
`@objectstack/runtime: patch`. No BREAKING banner: that belongs to R2.

## Base

Branched from `origin/main` `fbc12be318de0713e82e1f38ab804b5b63478e64`;
the dispatch order read `72eeabd3`, which had already moved when the
worktree was created.

⚠️ **This head carries a base merge.** PR objectstack-ai#19473 landed on `origin/main`
and touched `packages/runtime/src/domains/packages.ts` (+96/−8) —
carrier objectstack-ai#9 of this diff — so the branch went `dirty` and `origin/main`
was merged in at `32b5831c4e48ac9b1626322cf185b286a09073aa`. ⇒ **the
base every line number in this body is measured against is `32b5831c`,
not `fbc12be3`**, ⚠️ **and every pointer moved, not only
`packages.ts`.** Each carrier file gained an `import … from
'./version-grammar'` line, so each carrier sits one line lower than it
did at `fbc12be3`; `plugin.zod.ts` and `plugin-loader.ts` moved +4
because their comment blocks grew as well, and `packages.ts` moved `1680
→ 1770` (the merge plus the resolved comment block). The tables above
now carry BOTH columns, every cell verified line-by-line against this
head with a required-substring check. An earlier revision of this body
put the `fbc12be3` numbers in a column headed `line` beside one headed
`after`, which resolved to the wrong line on the head that ships.

`origin/main` has since reached `8015dc85` (one commit, objectstack-ai#19471), which
is ⛔ **not** an ancestor of this head and touches none of the 14 files
here — `git diff --stat 32b5831 8015dc8` over the 14 paths is empty.
The queue rebuilds on current `main` regardless.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nance instead of a liveness test; the reader accepts it and C9 keeps one red (objectstack-ai#19502)

Fixes objectstack-ai#19240
Clause-②: yes

`Clause-②: yes` — the claim reader's accept set widens (a cross-login
`Release:` carrying provenance now retracts) and C9's judged set narrows
to a bare cross-login `Claim:`; a `.claude/**` surface ⇒ Tier S, the
seat lands it on its `## Contract review` PASS + `--pair` 0. This PR
stays draft.

## What lands — ruling 5754797404, shape A, executed as ruled

**The claim HANDOVER protocol.** A card whose claimant is unreachable
(token exhausted, session ended, identity retired) is taken over by a
new session in ONE comment, and the claim reader accepts that comment —
no liveness heuristic anywhere: the human's word, copied with
provenance, is the permission.

| # | Surface | Change | Net lines |
|---|---|---|---|
| 1 | `scripts/pm/check-clause2-carriers.mjs` | `claimRetractions` gains
the HANDOVER arm; `CLAIM_RETRACTION_RULE`, `CLAIM_HANDOVER_RULE`,
`CLAIM_HANDOVER_REMEDY` rewritten; C9 keeps one red and lists refused
handover attempts; self-tests both sides (1075 → 1091 cases) | +174 /
−54 = **+120** (the claim's budget, exactly) |
| 2 | `.claude/skills/pm-dispatch/SKILL.md` | :177 · :472 · :492 aligned
in place; the nine liveness-heuristic bullets (:493–:501 at base)
replaced by five handover bullets | 813 → **809** (net −4; ceiling 813,
headroom 4) |
| 3 | `.claude/skills/pm-dispatch/references/core-rules.md` | the two
twins (:110, :111) rewritten in place | 151 → **151** (net 0) |
| 4 | `.claude/agents/os-dev.md` | :94 in place: every compilable step
is pushed; a handover reads the remote branch's last sha | 403 → **403**
(net 0) |
| 5 | `scripts/pm/check-half-states.mjs` | **untouched** — measured:
`grep -n 'author !== '` → 0 hits; its `Release:` readers (H47
`latestMarkedComment` / `releaseAnswersClaim`) compare comment ORDER,
never authors, so it carries no copy of the retraction rule and imports
nothing from the clause-② reader | 0 |

Base `32b5831`, `origin/main` merged once at `d00692f` (PR objectstack-ai#19462 had
not landed at 2026-09-21T04:1xZ — SKILL.md ceiling stays 813, no region
overlap). Every line ≤ 120 bytes; `check:pm-skill-ratchet`,
`check:pm-skill-id-lint`, `check:pm-governed-prose`,
`check:agent-model-declared`, `check:nul-bytes` all exit 0 on the edited
files.

## 1. The reader

### The HANDOVER arm of `claimRetractions` (the one accept-set widening)

A `Release:` comment by a **different login** retracts an earlier claim
when, and only when:

- (a) its `Release:` **line** (the first line of the body that
`markerMatches(RELEASE_COMMENT_MARKER, line)` reads — the sibling's ONE
reading, applied per line, so `**Release:**` and `` `Release:` `` read
and `- Release:` does not) names the retracted claim's **comment id**
(digit-bounded) **and** its **session id** (token-bounded; the claim's
`Session:` line first, else the first `session_…` token in the claim
body — a claim with none cannot be named, fail closed);
- (b) the comment carries the three provenance fields of SKILL.md's 出处三件
line 「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」, each with a
**non-empty** value.

Missing any one piece ⇒ NOT a retraction, state unchanged. ⛔ No liveness
test: the earlier claimant's later comments are irrelevant (pinned).
Same-login retractions: byte-for-byte the old behaviour (no id, no
session, no provenance needed).

**Pinned key spellings** (`HANDOVER_PROVENANCE_KEYS = ['谁的指令', '原话',
'在哪说']`, exactly the :149 vocabulary — ⛔ no fourth key, ⛔ no synonym). A
field is: the key · optional decoration (`*`, `_`, backticks) · an
optional parenthetical `(…)` / `(…)` · a colon (ASCII `:` or fullwidth
`:` — indistinguishable on the page, pinned equal) · the value = the
rest of that line up to the next key, or, when that is blank, the
blockquote (`>` lines) under the key. Whitespace, `>`, decoration and
separator punctuation alone are an EMPTY value (pinned per key).

The two live specimens, both replayed verbatim in the self-test:

- 5754797404 (inline paragraph): `**出处三件** —
**谁的指令**:维护者,在本席(…)会话内的三个真实用户轮次。**在哪说**:本席会话聊天,在评论
5754717208(2026-09-21T02:44Z)之后、本条之前的连续三轮。**原话**(逐字,⛔ 未翻译、未润色):`
followed by the blockquoted turns.
- 5754717208 (line per field): `**出处三件**——` / `**谁的指令**:维护者(本仓
maintainer,…)。` / `**在哪说**:本会话聊天内,…。` / `**原话**(逐字,⛔ 未翻译、未润色):` followed
by the blockquoted turns.

### C9 keeps exactly one red

`claimHandovers` is unchanged in its walk: it reads the LIVE claims
through the same `claimRetractions` map, so a handover comment (①
provenance `Release:` naming the holder's claim + ③ new `Claim:` with
`Branch:`/`Clause-②:` in the SAME comment) leaves one author holding ⇒
no row, no note, and the new `Claim:` is the governing claim on the
`--pair` path (a comment is not later than itself, so it cannot retract
its own claim — pinned). The one red left: a cross-login `Claim:` with
NO `Release:` at all for the earlier claim — a real claim-jump.
`CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` stays; its gating now applies to
that narrowed red only (it is read at the same place as before).

**Loud refusal, not silent red:** a cross-login `Release:` that TRIED to
hand over a live claim (names its id or session id, or carries a
provenance field) and did not is listed in the C9 sentence with its
reason — `missing 在哪说`, `the comment id is not on its `Release:` line`,
`the session id is not on its `Release:` line`, `the claim carries no
session id to name`. A bare `Release:` by another login (a seat
releasing its own claim) is not an attempt and is not listed — the first
draft listed those and the `--pair 19373` row named os-steve's own two
releases as "refused handovers" of os-bill's claim, which was noise;
narrowed.

**The remedy sentence** (`CLAIM_HANDOVER_REMEDY`) prescribes the
four-item handover comment and prints SKILL.md's handover sentence
**verbatim** (`CLAIM_HANDOVER_SENTENCE_LINES` = the five 认领 bullets,
byte for byte), citing the 出处三件 line as its source. The old remedy words
「the HOLDER posts `Release:` … the TAKER posts nothing until then … ⛔
never a `Release:` on the holder's behalf」 are gone; ② (assignee swap)
and ④ (the sha record) are stated as the seat's acts, unread by the
reader.

### Self-tests (beside the existing retraction and C9 cases, ⛔ not at
`selfTest()`'s tail; floor unchanged)

Retraction battery: ⭐ a cross-login provenance `Release:` naming id +
session is accepted — state `declared`, the handover's own `Claim:`
governs, the record says "a DIFFERENT login … HANDOVER" · ⛔ missing any
one field, or a key with an empty value ⇒ refused, one case per key each
way, the missing key named · ⛔ id without session / session without id /
both in prose under a bare `Release:` line / the three fields with no
`Release:` line at all ⇒ refused · ⭐ NO liveness test: the earlier
claimant commenting after the handover changes nothing · ⭐ both live
specimens' spellings read, and a fullwidth colon reads as the ASCII one
· ⛔ same-login `Release:` still needs nothing (arm untouched); a claim
with no session id cannot be handed over · ⛔ item ④ absent still
retracts (the seat's act, not the reader's gate) · the printed rule
names both arms, the three keys, the source line and the absent liveness
test.

C9 battery: ⭐ the handover comment clears C9 (no row, no note) · the
handover's `Claim:` is the governing claim on `--pair` (branch,
declaration) and is not self-retracted · ⛔ the same comment missing any
one field ⇒ still C9 JUDGED, the row names the refused release and the
missing key · ⛔ the ONE red kept: a cross-login `Claim:` with no
`Release:` at all · ⛔ a handover naming only one of two live claims
leaves the other standing · the remedy is SKILL.md's handover sentence
verbatim, with the 出处三件 source line and 让先到者 for a yield · each sentence
line is one SKILL.md bullet by shape (≤ 120 bytes, no bullet, no issue
id).

## 2. Before / after — every changed instruction line

`.claude/skills/pm-dispatch/SKILL.md`

| line (base → now) | before | after |
|---|---|---|
| :177 → :177 | `- dev 自己死了不等于维护者中止:子代理消失是正常死法,走死认领回收。` | `- dev
自己死了不等于维护者中止:子代理消失是正常死法,走接管(见认领节)。` |
| :472 → :472 | `- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段。` |
`- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段,接管同此。` |
| :474 | `- 释放是显式动作:让卡离手者同笔清 assignee + `Release:` 行(会话/因/去向);下一任重新认领。`
| **unchanged, deliberately** — this line is the greppable source of
`RELEASE_ACT_RULE` in `check-half-states.mjs` (outside this claim's
surface); the handover reuses the act's two halves (② assignee + ①
`Release:` line, by the taker), stated in the new bullets |
| :492 → :492 | `- dev 侧早推分支,远程分支是在飞工作最硬的证据。` | `- dev 每个可编译小步即
push:容器随会话回收,未 push 的树救不回,可交接的只有远程分支。` |
| :493–:501 → :493–:497 | the nine liveness bullets (listed in §3) | `-
认领人不可达(token 耗尽/会话结束/身份退役)⇒ 接管:一条评论四件齐,⛔ 不判死活。` / `- ① 跨账号 `Release:`
点名被撤认领的 id 与 session ID,带出处三件(谁的指令/原话/在哪说)。` / `- ② assignee
同笔换人(`--unassign 旧 --assign 新`);③ 新 `Claim:`:新 session、续用分支与远程 sha。` /
`- ④ 交接记录:旧分支最后已 push 的 sha + 一句状态;读者只验①③形状,缺一件即非撤销。` / `- C9 只剩一种红:无任何
`Release:` 的跨账号 `Claim:`(真抢卡);线程上每条活认领都要点名。` |
| :502 → :498 | `- 误伤活席位 ⇒ 令其追加式更正,落 PR 正文不落分支历史。` | unchanged (a
mis-handed live seat still appends its correction) |

`.claude/skills/pm-dispatch/references/core-rules.md`

| line | before | after |
|---|---|---|
| :110 | `- 更早的他会话认领即让行并交出已诊断的一切;认领逾一天且无合并证据即疑死。` | `-
更早的他会话认领即让行并交出已诊断的一切;认领人不可达即接管,⛔ 不判死活。` |
| :111 | `- dev 自死不等于维护者中止,需显式信号;回收前先救工作树,有提交的活分支 ⛔ 永不回收。` | `- dev
自死不等于维护者中止,需显式信号;接管一条评论四件齐,只救已 push 的分支。` |

`.claude/agents/os-dev.md`

| line | before | after |
|---|---|---|
| :94 | ` - 有可展示内容即 commit、push 并开 draft PR,不等验证结束;验证结果到达即写进报告。` | ` -
每个可编译小步即 commit + push;有可展示内容即开 draft PR;接管只认远程分支最后 sha。` |

The dropped tail 「验证结果到达即写进报告」 survives at os-dev.md :95 (「未读到的判决写 NOT
MEASURED」) and :311 (「报告在本地验证走完时交付」).

## 3. SKILL.md deletion list — each retired line's surviving home

| retired line (base :493–:501) | surviving home |
|---|---|
| `死认领回收:认领 >~24h ⇒ 疑死;判死主腿 = 搜引用本卡的 PR、读其 merged/merged_at。` |
**retired outright** — the ruling replaces liveness judgement with the
human's word (:493 「⛔ 不判死活」) |
| `⛔ 判死不读 closes-list;承诺分支缺席与提交扫描失效只能支持判死、永不单独确立。` | retired outright
(no liveness judgement exists to bound) |
| `零引用 PR ⇒ 停下发问,⛔ 不判什么都没落地。` | retired outright; the "ask first" half
is the protocol itself — the handover IS the human's answer copied with
provenance (:494) |
| `回收前先救工作树:向任何派发 worktree 提交前先过存活/所有权检查。` | **retired outright** — the
hard fact at :492: a remote container's worktree is reclaimed with the
session; there is nothing to rescue |
| `或对树最新 mtime 过明确年龄阈值;⛔ 不凭 GitHub 侧静默动手。` | retired outright (same
reason); 「⛔ 不凭 GitHub 侧静默动手」 survives as the provenance requirement
(:494) |
| `过栏后,派发 worktree 的未提交改动先 WIP commit 到派发分支并 push,sha 记进回收评论。` | :492
(every compilable step is pushed by the dev — the WIP-rescue is moved to
the writer side, before the cut) + :496 ④ (the last pushed sha in the
handover record) |
| `WIP commit 标 INCOMPLETE AND UNREVIEWED;续派者 diff 它,⛔ 不无审续建。` | :496 ④
「一句状态」 — the taker records the branch's state and continues from the
remote sha; "diff before continuing" is the taker's ordinary care under
「读者只验①③形状」 |
| `WIP 信息只写观察到的(脏路径/行数/sha),⛔ 不写席位行为的现在时断言。` | :496 ④ (sha + one status
sentence) — no WIP commit is written by anyone but the dev itself |
| `再评论询问,静默一窗后释放回队(`Release:` 行载因);有带提交活分支的认领永不回收。` | :494 ① (the
`Release:` line, now with provenance instead of a silence window) + :497
(every live claim named) — 「有带提交活分支的认领永不回收」 is retired: a pushed branch
is precisely what the handover continues (:495 ③) |

## 4. PM mechanism assumptions — verified, one refuted

1. ✓ At `5e7d83c` = `32b5831` (no diff on the surface between them):
`CLAIM_RETRACTION_RULE` :1731 stated "⛔ never a DIFFERENT author's
line", `claimRetractions` skipped every candidate whose author differs
(:1778 `candidate.author === null || candidate.author !==
claim.author`), and `claimHandovers` judged cross-login claims after
`CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` (:2073, `2026-09-19T03:45Z`).
2. ✓ Reproduced before the change (2026-09-21T03:5xZ,
`PM_SWEEP_REPO=objectstack-ai/objectstack`): `--pair 19373` → exit 4, `✗
C9 — card objectstack-ai#17518 (delivering open PR objectstack-ai#19373) — 2 authors hold LIVE claim
comments … `os-bill`'s 5646971772 at 2026-09-12T15:54:12Z is the claim
that stood; `os-litant`'s 5749581295 at 2026-09-20T11:43:41Z took the
card from `os-bill` (dated AFTER the effective instant 2026-09-19T03:45Z
— JUDGED)`; `--pair 19335` → exit 4, `✗ C9 — card objectstack-ai#18670 (delivering
open PR objectstack-ai#19335) — 3 authors … `os-litant`'s 5717305863 … stood;
`os-steve`'s 5736537462 … (listed, informational); `os-bill`'s
5749165780 at 2026-09-20T10:14:08Z took the card from `os-steve` (…
JUDGED)`. After the change both STILL exit 4 (same rows, the remedy now
printing the four-item comment) — as predicted, until the seats post the
handover comments below.
3. ✓ SKILL.md :149 reads exactly
「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」 (ASCII punctuation); the
reader now reads exactly those three fields and quotes the line unbroken
(`HANDOVER_PROVENANCE_SOURCE`).
4. Tier S — `node scripts/pm/check-governed-merges.mjs --pr N` is run
once the PR number exists; the result is in the report. The PR stays
draft.
5. **REFUTED — ruling item ② spelling.** `label-write --clear-assignees
--assign NEW` is refused by the tool: `--clear-assignees cannot be
combined with --assign/--unassign` (`scripts/pm/label-write.mjs`
:417–:419). The one-write assignee swap is `node
scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue N
--unassign OLD_LOGIN --assign NEW_LOGIN` (`computeAssigneeTarget`:
target = current − unassign + assign, one write, read back). SKILL.md
:495 and the handover comments below use that spelling.

## 5. The handover comments the seats post (verbatim — ⛔ not posted by
this PR, ⛔ nothing written on objectstack-ai#17518 / objectstack-ai#18670 / PR objectstack-ai#19373 / PR objectstack-ai#19335
here)

Both were **simulated offline against the live threads** (the REST rows
of each card plus the drafted comment appended): C9 state `null`
(clear), pool = the handover comment, governing branch = the continued
branch, declaration `declared` / `yes`, C8 = 0; controls — the same
comment without 在哪说 ⇒ C9 judged `true`; the same comment with the
session id blanked on the `Release:` line ⇒ C9 judged `true`.
Placeholders in CAPITALS are the poster's to fill (its own session id /
login, the UTC stamp). The 原话 / 在哪说 values copy the maintainer's words
that adopted this protocol for exactly these two PRs (5754717208 § the
maintainer's turns; 5754797404 「同意」, which names PR objectstack-ai#19373 and PR objectstack-ai#19335
as the two the ruling unblocks); a fresher instruction naming the card
directly is a better value, if the seat has one.

### objectstack-ai#17518 (PR objectstack-ai#19373) — posted by the `domain:spec#1` seat
(`os-litant`, the taker already holding claim 5749581295)

```text
Release: handover of claim 5646971772 (`session_01MkQhmuuJAVDjmeWNixwDDH`, `os-bill`, branch `claude/issue-17518-assembled-body-json-schema`) and of this seat's own claim 5749581295 (`session_01LvwGppdonww4zGLWZo5rho`) · 因: the earlier claimant is a dev subagent session that ended on 2026-09-12 and cannot post its own `Release:`; the taker has delivered the whole diff on PR objectstack-ai#19373 · 去向: the `Claim:` below — same seat, same branch
谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2)
原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」
在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session)
Assignee: `os-project-manager` → `os-litant`, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 17518 --unassign os-project-manager --assign os-litant`
Claim: `domain:spec` seat 1 takes over objectstack-ai#17518 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC
Session: `session_01LvwGppdonww4zGLWZo5rho`
Branch: `claude/issue-17518-assembled-package-body-inert-json` (continued at remote sha `aac764cc36113b4e52820c1695715f000ccbe1b4`, the head of PR objectstack-ai#19373)
Clause-②: yes
Handover: the released claim's branch `claude/issue-17518-assembled-body-json-schema` — last pushed sha `ed8dea17bd510100320ab42dbac6ec2a78e99deb` (read from origin at 2026-09-21); status: superseded — the whole diff was re-delivered on PR objectstack-ai#19373 at `aac764c` (checks green, `## Contract review` pending), nothing from the old branch is carried.
```

Why the seat's own 5749581295 is named too: the reader would otherwise
carry TWO live `Claim:` comments by `os-litant` (C8). Named on the same
`Release:` line it is retracted by the same-login arm, and the fresh
`Claim:` in this comment is the only one standing. If the posting
session differs from `session_01LvwGppdonww4zGLWZo5rho`, the `Session:`
line carries the new one.

### objectstack-ai#18670 (PR objectstack-ai#19335) — posted by the live `domain:spec` seat
(POSTER_LOGIN / POSTER_SESSION_ID; the taker of record,
`session_01JbZnqu8bt6YqfJsr9vaFb3`, was retired at 2026-09-20T23:34Z)

```text
Release: handover of claims 5717305863 (`session_01LvwGppdonww4zGLWZo5rho`, `os-litant`, branch `claude/issue-18670-refinement-projection-census`), 5736537462 (`session_01AmH9bKvGoLjiY86Q4Z3og2`, `os-steve`, branch `claude/issue-18670-banned-keys-projection`) and 5749165780 (`session_01JbZnqu8bt6YqfJsr9vaFb3`, `os-bill`, branch `claude/issue-18670-propertynames-not-pattern-arm`) · 因: the first two claims' work is merged (PR objectstack-ai#18729, PR objectstack-ai#19137; both branches absent on origin), and the third claim's session was retired at 2026-09-20T23:34Z with its PR objectstack-ai#19335 reviewed and green — none of the three can post its own `Release:` · 去向: the `Claim:` below
谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2)
原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」
在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session)
Assignee: `os-bill` → POSTER_LOGIN, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 18670 --unassign os-bill --assign POSTER_LOGIN` (a no-op when the poster IS `os-bill`; `pm:blocked` → `pm:dispatched` in that same write once the reader has landed)
Claim: `domain:spec` seat takes over objectstack-ai#18670 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC
Session: `POSTER_SESSION_ID`
Branch: `claude/issue-18670-propertynames-not-pattern-arm` (continued at remote sha `1dfe2f40bce77270758d9b31b01dd8d46875a290`, the head of PR objectstack-ai#19335)
Clause-②: yes
Handover: branch `claude/issue-18670-propertynames-not-pattern-arm` — last pushed sha `1dfe2f40bce77270758d9b31b01dd8d46875a290` (read from origin at 2026-09-21); status: `## Contract review` PASS recorded at 5749728565 on this head, both carriers stripped, checks green — nothing left to build, the landing is the only step. The two older branches are absent on origin (their work merged as PR objectstack-ai#18729 / PR objectstack-ai#19137).
```

Why all three claims are named: C9 walks every LIVE claim; naming only
5749165780 would leave `os-litant` → `os-steve` → NEW as two hand-overs,
the last dated after the instant — still red. The row prints exactly the
ids to name (:497 「线程上每条活认领都要点名」).

## 6. Four-axis analysis

### 「No liveness test」 (ruling; 5754717208 §4's 「点名的是活认领 ⇒ 拒」 not kept)

- **实际业务需求** — measured: the three specimens (objectstack-ai#17518 / PR objectstack-ai#19373; objectstack-ai#18670
/ PR objectstack-ai#19335; objectui#9370) are all cases where the human already knew
the claimant was gone and the machine could not: a subagent session that
ended 2026-09-12, a seat session retired at 23:34Z, a retired identity.
In every one the holder's silence was total, so a liveness heuristic
(>24h, later comments, PR search, mtime) would have said "dead" only by
luck of thresholds, and a holder that posts one late comment would have
flipped a correct takeover into a refusal. The maintainer's words:
「这种情况通常都是人类口头交代的」 — the decision is already taken by a human; the
reader's job is to verify the copy, not to re-decide.
- **项目长远合理性** — a reader that verifies provenance is a pure function of
the thread (contract-first, no workaround); a liveness heuristic is a
second, contradictable oracle beside the human's word and needs its own
thresholds, exceptions and reconciliation windows (which is what
:493–:501 had become: nine lines of them). Long-term cost of the chosen
option: a bad handover is possible on a bad instruction — but it is
auditable (谁的指令 / 原话 / 在哪说 are on the card) and repairable (:498 「误伤活席位
⇒ 令其追加式更正」).
- **防 AI 写错** — the accept set is closed and mechanical: three named
keys, an id + session on ONE line, fail closed on any gap, and the
refusal names the missing piece. Nothing to guess; an AI seat that
half-writes the comment is told which field. A liveness test would be
the opposite — a tolerance rule ("probably dead") that hides a wrong
takeover behind a green.
- **创业阶段不扩散需求** — the ruling's own reason: 「我们系统开发了太多无用的门禁,反而在浪费时间」.
Nine heuristic lines retired, five protocol lines added, no staged
transition (the heuristics are gone at once — 「短期不考虑渐进」).
- Recommendation held: no liveness test, per the ruling.

### 「C9 keeps one red」 (a bare cross-login `Claim:` with no `Release:`
at all)

- **实际业务需求** — the red exists for the measured claim-jumps (objectstack-ai#17852's two
seats eight hours apart, objectstack-ai#15811's silent assignee move); those are
exactly the shape left red. The two finished PRs it blocked were
handovers, not jumps — they had no channel to say so; now they have one
comment.
- **项目长远合理性** — one state, one row, one repair (the handover comment) —
no widening of C8, no second selector; the effective instant stays as
history and still gates the narrowed red only.
- **防 AI 写错** — deleting C9 would let any later `Claim:` silently govern
(the pre-objectstack-ai#18862 SUPERSEDED exit-0 reading); keeping the red but printing
the four-item comment as the remedy makes the correct act the shortest
path. A refused attempt is listed with its reason instead of a bare "2
authors hold live claims".
- **创业阶段不扩散需求** — no new gate, no new label, no new tool: the remedy is
a comment in the spelling the protocol already has; the only added code
path is the provenance read.
- Alternative weighed and refused: retiring C9 entirely (「太多无用的门禁」) —
refused because the ruling itself keeps 「真正的抢卡」 red, and a jump is a
real, measured, silent failure.

## 7. Tests and gates (head `7a66ffe`; every exit captured before any
pipe)

- `node scripts/pm/check-clause2-carriers.mjs --self-test` → exit 0,
**1091 cases pass** (1075 at `origin/main`, run from a temp copy in the
same tree; the roster floor unchanged; both new case groups sit inside
their existing batteries).
- `--pair 19373` / `--pair 19335` → exit 4 before AND after (rows quoted
in §4.2); after the change each row ends with the four-item remedy
(`grep -c 认领人不可达` = 1 per log).
- Offline simulation of the two handover comments (§5): C9 clear,
governing claim = the handover, declaration `declared/yes`; controls
red.
- Derived union (`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, 48 commands, derived from the tree at
`693afd7` after the `origin/main` merge and re-run at `7a66ffe`): all
**48 of 48** commands exit 0 (run 2026-09-21T03:56Z–04:15Z, sequential,
each exit captured before any pipe; the list reconciled with `--ran`);
the slowest, `pnpm check:pm-dispatch-gates`, ran its full 1883-case
battery green at this head.
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`
first answered exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` /
`@objectstack/lint` not built — NOT a finding); after `pnpm exec turbo
run build --filter=@objectstack/formula --filter=@objectstack/lint`
under `os-verify-lock.sh` (VERDICT command-exit 0, 203 s) it answers
exit 0.
- `pnpm check:pm-dispatch-gates` (845 s on this box) red once on an
EARLIER draft: its governed-read census found a `readFileSync` of
SKILL.md in this reader's self-test (my "same words" pin). Removed — see
Deviations — and re-run green at the final head.

## Deviations (declared)

1. **The "ONE sentence" property is not a governed read.** A self-test
pin that reads SKILL.md makes `check:pm-clause2-carriers` a derived
family of SKILL.md and needs a `GOVERNED_READ_FLOOR` row in
`scripts/pm/dispatch-gates.mjs` (outside this claim's surface; a
gate-derivation change). Kept instead: `CLAIM_HANDOVER_SENTENCE_LINES`
(the remedy prints the five lines verbatim) + the twin rule at review +
a shape pin (each line ≤ 120 bytes, no bullet, no issue id). Open
question for the seat: register the read so a SKILL.md edit that breaks
the sentence reds the reader (recommended; a two-line floor row).
2. **SKILL.md ceiling not lowered** (813 → could be 809):
`check-skill-line-ratchet.mjs` is outside the surface; headroom 4 is
reported, the seat lowers it if wanted.
3. **:474 left byte-identical** (see §2) — the alignment the dispatch
asked for is carried by the new bullets rather than by editing the line
that a sibling file quotes.
4. **Ruling ② spelling corrected** (`--unassign OLD --assign NEW`), see
§4.5.

## Acceptance notes (off-path; noted, not filed — ⛔ no card filed by
this dev)

- `scripts/pm/check-half-states.mjs` H47 leg (b) sentence still quotes
「释放回队(`Release:` 行载因)」 as "the dead-claim route" — that SKILL.md line is
retired here, so the quotation is stale prose in a remedy sentence (a
doc nit, not a defect; carrier: the `domain:skills` seat on its next
half-states touch).
- `references/platform-readings.md` :391 「处置 = 死认领回收加 worktree 抢救,⛔
不重核前提、不升级」 names the retired route (a host-signal disposition line;
outside this claim's surface — the seat's twin-rule follow-up, one line:
「处置 = 接管(认领节),⛔ 不重核前提、不升级」).
- `check-clause2-carriers.mjs`'s C9 docblock still carries the objectstack-ai#18862
ruling history verbatim (「the holder posts `Release:`; the taker posts
nothing until then」 as the ruling's quoted words) — kept as history, the
new paragraph below it states the change; no action.
- `.claude/skills/pm-dispatch/SKILL.md` :272 「维护者强制接管令 … ⛔
不取在飞卡,由原认领者跟完」 is the seat-level forced takeover (a blanket order) and
is not contradicted by a per-card handover on a named instruction; left
as is.

## 维护者速读(草稿)

**改了什么**:把「死认领回收」换成「接管协议」。一个 agent 做到一半没 token 了,新会话在**一条评论**里接管:① 跨账号
`Release:` 点名旧认领的评论 id 与 session ID,并带出处三件(谁的指令 / 原话 / 在哪说);② assignee
同笔换人;③ 新 `Claim:`(续用远程分支与 sha);④
一句交接状态。认领读者(`check-clause2-carriers.mjs`)按形状接受①③,不再判死活;C9 只剩「没有任何
`Release:` 的跨账号抢卡」一种红。SKILL.md 删掉九行判死启发式,换成五行接管规则;os-dev.md
把「早推分支」提为硬要求(每个可编译小步即 push)。

**为什么改**:两张已复核完毕的成品 PR(objectstack-ai#19373、objectstack-ai#19335)今天落不了地,只因为旧认领人已经不在、没人能替它写
`Release:`;而「代执行他人指令要带出处三件」这条规矩早就在 SKILL.md
里,只是读者不读。您的原话:「这种情况通常都是人类口头交代的……我们系统开发了太多无用的门禁」。


**风险与代价(含回滚)**:风险是一条编造出处的接管评论会被读者接受——但出处三件留在卡上可审,误伤活席位按既有规则追加更正。代价是读者多一条判形状的分支(+120
行,含自测)。回滚 = revert 本 PR,一次 revert 即回到判死启发式与旧 C9。

**席位意见**:(席位填写)

**你要做的**:本 PR 是受管面(`.claude/**`),由席位达档复核后落地,不需要您动手;落地后 spec 席按正文第 5
节的两条评论接管 objectstack-ai#17518 与 objectstack-ai#18670,两张 PR 即可入队。若您希望读者对「SKILL.md
与读者同句」做机械钉死(而非复核时人工核对),点一下头,席位在 `dispatch-gates.mjs` 登记一条 governed read
即可。

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…retired ADR-0048 claim (objectstack-ai#19445)

Fixes objectstack-ai#19358

Clause-②: no

Seat `domain:skills#2` (seat post objectstack-ai#19287), dispatched from the claim on
objectstack-ai#19358. Tier H: `skills/**` — this PR stays DRAFT; the seat reviews and
ACCEPTs, the maintainer's own click lands it.

## What changed

`skills/objectstack-ui/rules/pages.md`, authoring rule 2 only (:337–:341
at both `23f1de0` and this head `127576f`). The rule (the stem must
start with the package namespace) and the ADR-0048 citation stay; the
REASON sentence is replaced with the facts the source enforces. Net 0
lines (+3 / −3), one file.

Before (`23f1de0`, :337–:341):

```text
2. **Namespace-prefixed filename.** The filename stem becomes the doc
   `name` (`^[a-z][a-z0-9_]*$`) and must start with the package namespace
   (`crm_…`). Names share one flat, instance-global space with the URL, so
   a bare `user_guide` would collide across packages and fail at install
   (ADR-0048).
```

After (`127576f`, :337–:341):

```text
2. **Namespace-prefixed filename.** The filename stem becomes the doc
   `name` (`^[a-z][a-z0-9_]*$`) and must start with the package namespace
   (`crm_…`). `os build` refuses a bare `user_guide`
   (`docs/namespace-prefix`) — hygiene, not uniqueness: two packages
   coexist on one bare name (ADR-0048 §3.4).
```

## The three false clauses, re-measured on this tree, and the source
line beside each new clause

| # | the old text said | measured refutation (all at `23f1de0`,
unchanged at `127576f`) | the new clause it becomes |
|---|---|---|---|
| 1 | doc names share "one flat, instance-global space with the URL" |
`docs/adr/0048-cross-package-metadata-collision.md` :182–:183 「The
route/container coordinate for an installed package's UI is its
**package id**」 (§3.1); :222–:223 「resolves a bare name **within the
current package first**, keyed on the **package id**」 (§3.3);
`packages/objectql/src/registry.ts` :3452 `const storageKey = packageId
? withDisc(`${packageId}:${baseName}`) : bareKey;` — the storage key is
composite `PACKAGE_ID:NAME`, not one flat space | dropped; replaced by
"two packages coexist on one bare name" |
| 2 | a bare name "would collide across packages" | ADR-0048 :251–:252
「The cross-package **throw is retired**; two distinct packages coexist
on the same bare name by construction」 (§3.4); `registry.ts` :3472–:3477
「ADR-0048 §3.4 — the per-item CROSS-package throw is retired … two
installed packages shipping the same bare name (e.g. `page/home`)
legitimately COEXIST under distinct composite keys」 — and no throw
exists in :3458–:3511, only the ADR-0005 overlay `console.warn` |
"hygiene, not uniqueness: two packages coexist on one bare name
(ADR-0048 §3.4)" |
| 3 | it "would fail at install" | BUILD door:
`packages/cli/src/utils/collect-docs.ts` :654–:658 `if (namespace &&
!doc.name.startsWith(`${namespace}_`))` pushes `severity: 'error'`,
`rule: 'docs/namespace-prefix'`; `packages/cli/src/commands/compile.ts`
:802–:811 `if (docErrors.length > 0) { … this.exit(1); }`. INSTALL door:
`registry.ts` :4155–:4168 refuses on `manifest.namespace` ownership and
:1456 `constructor(namespace: string, existingPackageId: string,
incomingPackageId: string)` — `NamespaceConflictError` is built from a
namespace, never a doc name | "`os build` refuses a bare `user_guide`
(`docs/namespace-prefix`)" |

Reference wording the dispatch pointed at, unchanged and consistent with
the new sentence: `content/docs/ui/doc-pages.mdx` :68–:69 「The build
lint still requires every doc name to be **namespace-prefixed**, as a
same-package authoring-hygiene rule」 and :77–:82 「It is no longer
**load-bearing for uniqueness** … two installed packages may each ship a
doc with the same bare name and coexist」. The build-check pin:
`packages/cli/src/utils/collect-docs.package-docs.test.ts` :420–:424 (a
bare `playbook` → one `docs/namespace-prefix` at `severity 'error'`).

## Restate vs drop — the card's "First act", decided on the four axes

Option A (taken): restate — the enforceable fact plus one corrected
clause naming where the refusal happens and what the prefix is not for,
with the ADR section. Option B: drop to the bare enforceable fact ("must
start with the package namespace; enforced by `os build`") and a bare
`(ADR-0048)`.

- **实际业务需求** — the reader is an AI author of package docs in a customer
project (`skills/**` ships via `npx skills add` / `npm create
objectstack`). Both options let it comply. What the old sentence did in
practice was get copied as a *reason* into the author's own docs; B
leaves the "why" blank, and a blank "why" beside an ADR number is the
shape an author fills with the retired claim. A gives the one fact that
closes it. Neither option adds any capability.
- **项目长远合理性** — spec/ADR > implementation > docs. ADR-0048 §3.4 itself
classifies this lint as authoring hygiene (:258–:259), and the source
header (`collect-docs.ts` :32–:54) says the same. A keeps the skill's
sentence tied to the ADR clause it cites (§3.4), so a future change to
that clause has one sentence to update; B cites a document that then
says nothing about the sentence.
- **防 AI 写代码/元数据犯错** — A names the door (`os build`) and the rule id
(`docs/namespace-prefix`), so an author hitting the refusal can find it,
and states the negative ("not uniqueness: two packages coexist on one
bare name") that stops an AI from re-deriving the retired collision
claim or designing around a constraint that does not exist (e.g.
inventing cross-package-unique names). B is silent on exactly the claim
this card is about.
- **创业阶段不扩散需求** — both are net 0 lines; A costs +2 ratchet units (see
below), B would shrink. No new capability, no new surface, immediate
correction, no transition text.

Decision: A. The cost is stated honestly: this file's token headroom
goes from 2 to 0, so the next edit to `pages.md` pays by deletion.

Also considered and not written, for the byte budget: the second true
reason the source states — the prefix is what separates a same-package
link from a cross-package one, because a doc link is a bare `./NAME.md`
with nowhere to carry a package coordinate (`collect-docs.ts` :45–:54
and the check at :737 `if (namespace &&
!target.startsWith(`${namespace}_`)) continue;`). It is measured and
true; the sentence had room for one clause, and the §3.4 framing is the
direct refutation of the retired claim. "authoring hygiene" was trimmed
to "hygiene" for the same reason: the full spelling lands at 5504 units
against a 5501 ceiling.

## Published-skill ratchet readings (both readings, per os-dev.md)

- Gate: `node scripts/check-skills-token-ratchet.mjs`
(`scripts/check-skills-token-ratchet.mjs`, invoked directly in
`.github/workflows/lint.yml` :5527–:5528; unit = `ceil(utf8 bytes /
4)`). Ceiling row :473 `['skills/objectstack-ui/rules/pages.md', 5501]`.
- File, tokens: before 21996 bytes → 5499 units (headroom 2); after
22003 bytes → **5501 units (headroom 0)**. Gate line: `✓
check-skills-token-ratchet: skills/objectstack-ui/rules/pages.md is 5501
tokens (ceiling 5501; headroom 0).` Paid within the replaced span (148 →
155 bytes); ceiling row untouched.
- File, lines: before 448 → after 448 (net 0; the claim's budget was net
≤ +2).
- Package, tokens: bundle total after 140368 (gate output); before
140366 — the unit is per-file additive and only this file changed (+2).
- Package, lines (sum over all `skills/**/SKILL.md`, 10 files): before
6145 → after 6145. Sum over `skills/objectstack-ui/**`: 2155 → 2155.
- ⛔ `pnpm check:pm-skill-ratchet` is the `.claude/**` LINE ratchet and
deliberately excludes the published `skills/` root (its header says so);
it is not the gate this file answers to.

## Gates — derived union, every exit captured before any pipe,
reconciled with `--ran`

Derived by `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths; change set from merge base
`23f1de0`, 1 path, +3/−3, under the 5000 human-merge threshold).
Reconcile: `Run reconciliation — 23 derived, 23 run, 0 NOT-MEASURED, 0
UNRUN.` (exit 0).

| command | exit |
|---|---|
| `node scripts/check-ci-filter-parity.mjs` | 0 |
| `node scripts/check-closing-keyword-parity.mjs` | 0 |
| `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 |
| `node scripts/check-comment-mask-corpus.mjs` | 0 |
| `node scripts/check-doc-route-spelling.mjs --advisory` | 0 |
| `node scripts/check-doc-route-spelling.mjs --self-test` | 0 |
| `node scripts/check-skills-token-ratchet.mjs` | 0 |
| `node scripts/check-skills-token-ratchet.mjs --self-test` | 0 |
| `pnpm --filter @objectstack/lint run check:doc-formula-expressions` |
first run 3 (PREREQUISITE NOT MET — `dist/` of `@objectstack/formula` /
`@objectstack/lint` absent in a fresh worktree; NOT MEASURED); after
`pnpm exec turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint --concurrency=2` under `os-verify-lock.sh`
(`VERDICT command-exit 0`, held 390s): rerun **0** |
| `pnpm check:agent-test-spelling` | 0 |
| `pnpm check:corpus-claim-drift` | 0 |
| `pnpm check:cross-package-test-inputs` | 0 |
| `pnpm check:doc-authoring` | 0 |
| `pnpm check:driver-memory-census` | 0 |
| `pnpm check:gitlink-declared` | 0 |
| `pnpm check:nul-bytes` | 0 |
| `pnpm check:pm-governed-merges` | 0 |
| `pnpm check:refd-timer-probe` | 0 |
| `pnpm check:role-word` | 0 |
| `pnpm check:skill-compatibility` | 0 |
| `pnpm check:skill-frame-sync` | 0 |
| `pnpm check:skill-identifier-liveness` | 0 |
| `pnpm check:watch-hint-literal` | 0 |

Outside the derived union, run as the dispatch ordered: `pnpm
check:pm-dispatch-gates` launched detached with its exit captured to a
file; its verdict is reported in the `os-dev-report` comment on objectstack-ai#19358
(it was still in its self-test when this body was written). No package
build or test is owed: the diff touches no package (① empty), and
`packages/cli` / `packages/objectql` are cited, not edited. The `##
Contract review` for this Tier H head is the seat's; `node
scripts/pm/check-governed-merges.mjs --pr` on this PR is read after
creation and goes into the report comment.

## PM mechanism assumptions, verified

1. Open PRs touching `skills/objectstack-ui/**`: **0 of 21** open PRs
(read via REST `/pulls?state=open` + `/pulls/{n}/files`). Two open PRs
touch `skills/` at all, neither in this surface: objectstack-ai#19378
(`skills/objectstack-platform/SKILL.md`) and objectstack-ai#19373
(`skills/objectstack-platform/references/_index.md`) — the claim named
only the first; the second is a refinement of the reading, not a
conflict. Holds.
2. `check-governed-merges.mjs --pr` → read after creation; expected
GOVERNED / Tier H. Reported in the report comment.
3. The published-skill token ratchet
(`scripts/check-skills-token-ratchet.mjs`) and
`check:skill-identifier-liveness` are both in the derived union and both
green on this diff. Holds, with the ratchet's name pinned — it has no
`pnpm check:*` alias and is not `check:pm-skill-ratchet`.

## 维护者速读(草稿)

- **改了什么**:对外发布的 `objectstack-ui` skill 里「页面文档」编写规则第 2
条,只改「为什么要加命名空间前缀」那一句;规则本身(文件名必须以包命名空间开头)与 ADR-0048 引用都保留。
- **为什么改**:原句说「裸名字会跨包冲突、安装时失败,依据 ADR-0048」——而 ADR-0048 §3.4
恰恰宣布跨包冲突这一说法已退役(两个包可以同时用同一个裸名字,按包 id 各自解析),真正的拒绝发生在 `os
build`(`docs/namespace-prefix`),不在安装。这份 skill 是客户项目里 AI
写文档时读的权威,错误的理由会被原样抄进客户文档。
- **风险与代价(含回滚)**:纯文案,零代码、零发布产物、零 changeset;净 0 行;该文件的 token 上限余量从 2 变
0(下一次改这个文件须删字付账)。回滚 = revert 这一个 commit。
- **席位意见**:(留空,席位定稿)
- **你要做的**:Tier H,本 PR 保持 draft;席位完成 `## Contract review` 与 ACCEPT
后,由你点合并。

## Acceptance notes

- noted, not filed: `packages/objectql/src/registry.ts` :3458–:3463
still opens with the pre-§3.4 framing ("refuse it loudly if a DIFFERENT
code package already owns the same (type, name)") immediately above the
:3472 retirement note — a stale lead-in comment, no behaviour behind it
(no throw in :3458–:3511). Observation, outside the three filing
classes. 承接者: none known (no open PR touches `registry.ts` for this; not
scanned further).
- noted, not filed: `content/docs/ui/doc-pages.mdx` :76–:77 calls
`docs/namespace-prefix` "the same `namespace-prefix` lint the platform
applies to other named metadata"; in `os lint` the
`naming/namespace-prefix` rule (`packages/cli/src/commands/lint.ts`
:313–:321) is a *warning* on a name declared twice within one package,
while `docs/namespace-prefix` is an *error* on a missing prefix — same
name stem, different predicate and severity. Docs imprecision, not a
false claim about behaviour; out of this card's surface (the dispatch
marked that page read-only). 承接者: none.
- The claim's serial reading "0 of 20 open PRs" is 0 of 21 on this act,
with objectstack-ai#19373 as a second `skills/`-touching PR outside this surface —
recorded above, no action.
- ADR-0046 §3.2 (the origin of the retired framing) is objectstack-ai#19408's,
untouched here. objectstack-ai#19408 is not addressed here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…putes `hasMore` (objectstack-ai#19493)

Part of objectstack-ai#19543

Clause-②: yes

Door ① of three. `GET /api/automation/:name/runs` declared a pagination
parameter it never spent, and then reported — as a literal — that there
was
nothing more to fetch. Both halves are addressed here.

## The ruling, which is the maintainer's call and not this PR's

Comment `5754491070` on objectstack-ai#19365 records decision batch objectstack-ai#204 item 2,
⚠️ and neither that comment nor that card resolves any more — objectstack-ai#19365 was
removed from
the board on 2026-09-21 and GitHub cannot restore a number. The number
is kept here
rather than re-pointed, because the comment was never on any other card
and naming a
different one would be false. **The live record is objectstack-ai#19543**, the
rebuild, which carries
this ruling quoted verbatim together with what could not be recovered.
The ruling's own
durable copy is in this diff: the `reason` field of the D3 entry in

`packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts`.
letters
`C · C · A` per door, maintainer 「204 同意」 2026-09-21. For door ① the
ruling reads, verbatim:

> `cursor` is retired from `ListRunsRequestSchema`; `limit` stays (it is
read
> end to end and the Console's flow-runs page sends it today); the
engine
> reports truncation to the route and **`hasMore` is computed**, never
> hard-coded. A (a cursor protocol for a 100-row window) and B (retire
cursor
> and leave the lie) are ⛔ not taken.

⛔ Not re-adjudicated here. Letter A — building a cursor protocol — is
explicitly not taken, so no continuation token is minted and
`nextCursor` stays
absent.

**Why `Part of` and not a closing keyword.** Doors ② (export jobs) and ③
(AI
conversations) are ruled but gated on a cloud-repo reading riding objectstack-ai#19545
(the rebuild of objectstack-ai#19361, which no longer resolves), and
the ruling has the seat execute them on that reading's return without
re-entering the decision box. A merge that shut the card would strand
two-thirds of the ruled work, so the card stays open and the seat
re-labels it.
The gate `scripts/check-partof-closing-keyword.mjs` is the mechanical
half of
that, and its RULE 3 is why no sentence here binds a closing keyword to
a
number at all — not even one written to prevent an auto-close, which is
the
exact incident that gate exists for.

## The premise was re-measured, and one half of the card's body is false

Every reading below was re-taken on `origin/main` at `5e7d83c`, not
relayed.

| claim | reading |
|---|---|
| `cursor` declared, never read | **holds** — `ListRunsRequestSchema`
declared it; `AutomationEngine.listRuns` never looked at the option; no
emit site writes `nextCursor` |
| `hasMore` hard-coded | **holds** — `automation.ts` returned
`deps.success({ runs, hasMore: false })`, a literal, beside
`merged.slice(0, limit)` |
| `limit` declared, never read | ⛔ **FALSE** — read end to end |
| `.default(20)` unique to the export door | ⛔ **FALSE** —
`ListRunsRequestSchema` carries it too |

`limit` is read at the boundary (`parseIntegerParam`, with the `1..100`
bounds
taken off the schema itself), forwarded to `IAutomationService`, and
spent by
the engine as `RunStore.listHistory`'s window. It is also pinned by live
enforcement in `automation-runs-query-validation.test.ts`. **Retiring it
would
have been a regression, not a narrowing**, and the ruling says the
`/packages`
parent ruling `5651023067` does not transfer. Both corrections belong on
the
card's thread, which is the census.

## What "truncated" means at this seam

The tempting signal is `runs.length === limit`. It is wrong at exactly
one
input, and that input is undetectable from the response: **a flow
holding
exactly `limit` runs produces a window byte-identical to one held by a
flow
with ten thousand.** Reporting `true` for the first is as wrong as
`false` for
the second.

Only one of the three sources `listRuns` merges was ever capped — the
durable
history arm, because `RunStore.listHistory(flowName, limit)` takes the
window
as an argument. The paused arm and the in-memory ring are read in full.
So the
signal chosen is an **over-read of exactly one row**: the history arm is
asked
for `limit + 1`, and the merged, filtered, ordered set is compared
against
`limit`. Overflow means a run matched that this window does not carry.
The
extra row is dropped by the same `.slice(0, limit)` that was always
there, so
nothing on the wire widens.

⛔ `RunStore.listHistory`'s signature is deliberately **not** redesigned:
over-reading is expressible in the `limit` it already takes, so the
truncation
signal costs the store contract nothing.

Two things `hasMore` deliberately does not mean, both pinned:

- ⛔ **not** "retention evicted older runs" — a run the per-flow cap
discarded
does not exist any more; it is not "more" and no `limit` brings it back.
- ⛔ **not** "there is a next page" — nothing mints a cursor. The
caller's
  remedy is a wider `limit`, up to the declared 100.

**One honest residual, pre-existing and unchanged.** Under `?status=`,
the
history arm's window is still the newest `limit + 1` rows of *any*
status,
because `listHistory` has no status slot and the filter is applied to
what
comes back. A status-filtered `hasMore: false` therefore means "no
further
match within the scanned window", not "no further match exists". Pushing
the
filter down is a store-contract change; the engine's own comment already
recorded this for the listing itself, and it is called out in the new
test's
docblock rather than papered over.

## Behaviour changes on the wire

**1. `?cursor=a&cursor=b` answered `400 VALIDATION_FAILED`; it now
answers
`200` with the key ignored.** This reverses a decision recorded under
objectstack-ai#7300,
which chose to validate the key rather than decide it — the reasoning
being
that a future cursor implementation must not be the one to discover the
type
was never enforced. The ruling decides it instead: there will be no
cursor
implementation on this door, so a refusal would be validating a key the
contract no longer has. This route declares no closed query-parameter
set, so
an unrecognised name has never been refused here on its own account. The
old
refusal cases are superseded by cases asserting the opposite on the same
inputs — the shape objectstack-ai#7359 and objectstack-ai#8054 already used on this route's other
two
parameters.

**2. `hasMore` can now be `true`.** A request whose window is shorter
than the
matching run set receives `true` where it previously received `false`. A
caller
that read `false` as "this is the whole history" was always wrong and is
now
told so.

**3. A service implementing no `listRunsPage` answers `501`** naming the
member, never a `200` carrying a guessed `hasMore`. "Absence must be
loud" —
falling through to the domain's `404` would leave a caller unable to
tell "no
run listing is mounted here" from "no such flow". The `403` run-read
grant runs
ahead of the service probe and is unaffected, which is what that gate's
own
note already required.

## Shape of the change

- **spec** — `cursor: retiredKey(RUNS_LIST_CURSOR_REMOVED)`. A
tombstone, not a
deletion: the request schema is not `.strict()`, so a bare deletion
makes Zod
silently strip whatever a generated client keeps sending — a clean parse
and
a parameter that never takes effect, which is this defect re-created one
  layer down (ADR-0104). The form is copied from the landed sibling
(objectstack-ai#17667 / PR objectstack-ai#19364 — that PR number no longer resolves and has no
rebuild, being a merged PR rather than a card; card objectstack-ai#17667 resolves and
is the live record) rather than invented.
- **contract** — new optional `IAutomationService.listRunsPage`
returning the
  exported `RunListResult` (`{ runs, hasMore }`) — the shape
`IExportService.listExportJobs` already uses, minus the cursor nothing
mints.
  `cursor` leaves `listRuns`'s options in the same stroke.
- **engine** — `listRunsPage` holds the whole method; `listRuns` is its
`runs`
  half. ⭐ One implementation, two projections, so there is no second
merge/filter/sort to rot. This is also why ~120 existing `listRuns` call
  sites across `service-automation`, `plugin-approvals`, `examples/` and
  `packages/cli` are untouched.
- **ADR-0087** — `RETIRED_KEYS_BY_MAJOR[18]` entry plus the D3 semantic
entry
`automation-runs-cursor-retired`. No D2 conversion: a conversion
rewrites an
authored source or a stored `sys_metadata` row, and this shape is
HTTP-only.
  Registered at 18, not 17, per the sibling convention.
- **changeset** — `minor` across the three published packages, carrying
the
  ADR-0087 disposition `registered automation-runs-cursor-retired`.
- **docs** — `content/docs/automation/flows.mdx`'s REST route table
advertised `?cursor` on this
route. That row is false once the key is retired, so it now states the
retirement, that a
request still carrying the key is **ignored rather than refused**, and
that `hasMore` is
computed with a wider `?limit` as the remedy. Flagged by Docs Drift
Check (`5755158989`); the
other 10 pages it named document the DATA door's `hasMore` and are true
as they stand, so none
was edited. Written by the dispatching seat, not the implementer — the
implementer's one body
  write was spent at create.
- **SDK** — `@objectstack/client` declared `cursor` and appended
`?cursor=` on all three run-list
surfaces (`automation.runs.list`, `automation.listRuns`,
`client.environment(id).automation.listRuns`).
Retiring the key in the schema alone would have left the one generated
client this repo ships typing it
`string` and sending it into a route that no longer reads it — the
ADR-0104 silent strip the tombstone
exists to prevent, one layer down. The option and the emitter are gone
from all three, the URL pin is
inverted into a three-surface absence pin, and `'@objectstack/client':
minor` joins the changeset. Same
call the repo made when objectstack-ai#6361 retired the notifications `cursor`. Added
by the dispatching seat after the
at-tier contract review FAILed the previous head on exactly this; the
implementer's one body write was
  spent at create.



## Verification

- `automation-runs-query-validation.test.ts`: 48 → **51**, and every
assertion
  that moved is named. Removed: the `objectstack-ai#7300` cursor-refusal describe (3
parametrised cases) and 3 `?cursor=` preservation rows — superseded, not
deleted, with the replacement asserting the opposite on the same inputs.
Added: 6 retirement cases and 3 `hasMore`-relay cases. Changed: the
double
now serves `listRunsPage`, and `cursor: undefined` left 10 expected
options
objects. **The `limit` preservation rows are byte-identical otherwise**
—
  the door still forwards the caller's own window, never a widened one,
  because the over-read lives in the engine.
- New `run-list-truncation.test.ts` (14 cases) pins the boundary table —
fewer than / **exactly** / more than `limit` — plus a spy proving the
store
  is asked for `limit + 1`.
- `pnpm test`: runtime 271 files, service-automation 141 files / 1690
tests.
- `pnpm typecheck`: spec, runtime, service-automation — all green, no
new
  `test-typecheck-debt.json` entries.
- Derived gate union (`scripts/pm/dispatch-gates.mjs --commands`,
reconciled
with `--ran`): **112 derived · 110 exit 0 · 2 exit 3 (NOT MEASURED) · 0
unrun**. Exit codes were captured before any pipe. The two are
environmental
  refusals, ⛔ not findings and ⛔ not passes:
  `check-plugin-teardown-shape --self-test` cannot reach a commit-pinned
positive control in a shallow checkout (`--is-shallow-repository` is
`true`
here; **the gate itself ran, exit 0**), and `check:dual-build-cjs-loads`
refuses without a repo-wide build (38 packages carry no `dist/`). CI has
both. Two further families initially refused on the same prerequisite
class
  and were converted into real readings by building what they read:
  `check:skill-examples` (258 prose examples type-check) and
`check:type-check-debt` (4 ledger entries re-measured, 53 raw errors,
none
  above its recorded number).

## Serial constraints

Declared adjacency from the dispatch: PR objectstack-ai#19373 holds
`packages/spec/dropped-refinements.baseline.json`,
`packages/spec/api-surface/root.json` and
`packages/spec/export-origins/root.json`. **This PR moves none of those
three**
— regeneration landed on the `contracts` shards
(`api-surface/contracts.json`, `export-origins/contracts.json`) plus
`authorable-surface/api.json`, all disjoint. `origin/main` was merged
before
this reading and `check:generated` reports all 15 artefacts current.

## Acceptance notes

Out of scope, observed, ⛔ not filed and ⛔ not widened into this PR:

- **`ListRunsResponseSchema.nextCursor` stays declared and never
emitted.**
Not a contract violation — an absent optional key promises nothing — so
it
is not class (b), and minting one is letter A, explicitly not taken. Now
  commented in place. Whoever takes door ② or ③ touches the same file.
- **`GET /automation` (list flows) also ships a literal `hasMore:
false`.**
  Measured, and there it is *true*: the handler returns every name with
  `total === names.length`, so nothing is withheld. Recorded so the next
  reader does not read the two literals as the same defect. No card.
- **The `?status=` window residual** described above is a real narrowing
of
what `hasMore: false` can promise. It is pre-existing, it is the
engine's own
recorded limitation, and closing it is a `RunStore` contract change —
the
  ruling scoped this card to the truncation signal.

Deviations from the dispatch's declared file surface, both required by
the
ruling's own text and reported rather than taken silently:
`packages/spec/src/contracts/automation-service.ts` (the ruling's
"engine
reports truncation to the route" needs the contract member the route
calls),
and two `packages/runtime` test doubles that stub the run-list service —
`http-dispatcher.test.ts` and
`automation-run-read-permission-gate.test.ts`.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…y an index signature, and the runtime schema is the contract (objectstack-ai#20191)

Fixes objectstack-ai#19324

Clause-②: no

Ruling-ref: 5805795339 (batch objectstack-ai#218 item 4, letter 丙). This PR carries
out ruling item 1. It adds one docblock on
`RecordStagePackageBodySchema`, at its `ZodRawShape` cast, and one
beside `AssembledInstalledPackageSchema.manifest`. Both record the four
points the ruling names. ⛔ No type change, ⛔ no schema change. Ruling
item 2 holds: the client gap pin stays, and only its comment text moves.
Per ruling item 3, the card is done when this lands.

## What changed

- **`packages/spec/src/stack.zod.ts`, `RecordStagePackageBodySchema`**
- Its published docblock gains a section, "Its published type is
deliberately an index signature", which carries all four points.
- The internal note above the declaration now says what the `as unknown
as z.ZodObject(z.ZodRawShape)` cast costs, and that the cast emits
nothing.
- **`packages/spec/src/api/package-api-assembled.zod.ts`,
`AssembledInstalledPackageSchema`**
- The docblock ends with a new section, "`manifest`'s published type is
deliberately an index signature", directly above the `manifest` line. It
replaces the old one-paragraph pointer.
- Since `c23cfb346a` the assembled declarations live in this file, not
in `package-api.zod.ts`.
- **`packages/client/src/return-type-precision.test.ts`**
- The two comments that cited `packages/spec/src/stack.zod.ts:1283` now
cite `RecordStagePackageBodySchema` by name.
- They no longer say the gap is this card's to close: it is the accepted
static contract, and the pin reddens the day the body is typed
precisely.
- Comment text only. No assertion, no `@ts-expect-error` and no binding
moved.
- **`packages/client/src/index.ts`, `ObjectStackClient.packages.list`**
(patch round 2, `a2be2ff440`)
- The TSDoc paragraph that called the asymmetry "a KNOWN GAP rather than
a design", tracked on objectstack-ai#19324 and citing `stack.zod.ts:1283`, is
rewritten to the settled reading. The index signature is the accepted
static contract (letter 丙), the runtime Zod schema is the enforced one
so a row is narrowed by parsing, and A2 is recorded beside
`RecordStagePackageBodySchema`.
- Comment text only: every changed line of the file is a docblock line.
Nothing in the `automation` namespace is touched.
- **`.changeset/19324-record-stage-index-signature-docblock.md`**:
`@objectstack/spec` patch and `@objectstack/client` patch
(`b9b0d32d2f`).

## Which of the four points were already there

Read at `3bd28e2b2e`, before the edit.

| Ruling item 1 point | `RecordStagePackageBodySchema` |
`AssembledInstalledPackageSchema` |
|---|---|---|
| ① the published type is deliberately an index signature | Partial.
Only an internal "ANNOTATED structurally" comment, which the published
`.d.ts` drops, pointing up the file. | Partial. "The body half is
deliberately typed Record(string, unknown)". No consequence stated, and
not called the accepted contract. |
| ② the runtime Zod schema is the enforced contract | Absent at this
site. It was said only in `AssembledPackageBodySchema`'s internal note.
| Present in substance: "a wrong-shaped body is refused exactly as it is
there". Kept, restated in the new section. |
| ③ why (TS7056 / objectstack-ai#14513) | Pointer only, in the unpublished comment. |
Pointer only, and it named `AssembledPackageBodySchema` rather than the
schema `manifest` is built from. |
| ④ A2 is the precise form | Absent | Absent |

## Premise re-measured against the BUILT declarations

Spec built at `3bd28e2b2e` (lock verdict `command-exit 0`).

- `dist/api-assembled/index.d.ts` and `.d.mts` declare `manifest` as
`z.ZodType` of Record(string, unknown) on both sides.
- `dist/index.d.ts` and `.d.mts` declare `RecordStagePackageBodySchema`
the same way, at line 23236.
- A probe program ran tsc 6.0.3 (strict, NodeNext) through the package
`exports`. `--listFiles` shows 9 spec `dist` files and 0 spec `src`
files.
  - **Readings, exit 0 (all five compile):**
    - R1: `InstalledPackage` assigns to `AssembledInstalledPackage`.
    - R2: the whole union assigns to the assembled arm.
    - R3: any object assigns to the manifest.
    - R4: `string extends keyof` the manifest (an index signature).
- R5: a `{ bogus: 1, objects: 'not-an-array' }` manifest compiles
against the union.
- **Lit controls, exit 2, TS2322 twice:** the reverse assignment, and a
string manifest.
- **Runtime control:** `InstalledPackageAtEitherStageSchema.safeParse`
answers true for a valid authoring row, and false for the bogus row,
both on the union and on the assembled arm.
- The same readings hold at head `17f1e3d41c` after the edit, so the
types did not move.

## The TS7056 reading behind the "why", re-measured after the split

M1 is the decision round's reading at `d1ca8741dd`. This PR re-ran it at
`3bd28e2b2e`.

- **Mutation:** drop the artifact and record stages' structural
annotations and the `ZodRawShape` cast.
- **Result:** spec build exit 1 with exactly one error,
`src/api/package-api-assembled.zod.ts(222,14): error TS7056`. Line 222
is `PackageApiContracts`.
- **How:** through `scripts/ablation-replace.mjs`, with each anchor
hitting 1 then 0, and blob `3c09282f16` → `ac2e5f6b0b` → `7823e5e634`.
- **Restore proven:** blob == HEAD `3c09282f16`, `git diff HEAD` empty,
`git status --porcelain` empty.

The objectstack-ai#14513 history was read from commit `7085f90531`. It covers TS7056
on the inferred type, and a named alias that turned `stack.zod` into a
shared chunk. That chunk added 42,622 definition lines to the
`qa/http-conformance` type-check program and pushed it past the then
4096 MB ceiling.

The docblock says plainly that the named-alias reading is inherited for
the record stage and was not re-measured. A2's cost is quoted with its
commit (`d1ca8741dd`), as is the fact that it was not measured on the
http-conformance program.

## One bounded fix in the same docblock

The `AssembledInstalledPackageSchema` docblock said the assembled stage
was "built from `AssembledPackageBodySchema`". That has been false since
objectstack-ai#19373. The row's `manifest` is `RecordStagePackageBodySchema`, which
extends the artifact stage, and the artifact stage is
`ManifestSchema.extend({ ...assembledPackageBodyShape(), … })`. It now
reads "built from the same body shape as `AssembledPackageBodySchema` …
at the record stage the next section describes".

This was the decision round's own carrier note (comment 5800545221), and
it names this PR. Same docblock, same class (the text beside `manifest`
misdescribing its declaration), inside the claimed file surface, with no
new gate.

## Changeset, not `skip-changeset`: the edit ships

Measured on the rebuilt `dist` at `17f1e3d41c`:

| probe | files |
|---|---|
| new `RecordStagePackageBodySchema` section heading |
`dist/index.d.ts`, `dist/index.d.mts` |
| new `AssembledInstalledPackageSchema` section heading |
`dist/api-assembled/index.d.ts`, `dist/api-assembled/index.d.mts` |
| lit control: an existing line of the `RecordStagePackageBodySchema`
docblock | the same 2 files |
| the replaced site-2 sentence | 0 |
| the internal cast note (not TSDoc) | 0, dropped from `dist` as
expected |

Both edited files also ship as source, because `@objectstack/spec`'s
`files[]` carries `src/**/*.zod.ts`. So the edit is published and takes
a `patch` changeset, carrying `Clause-②: no`. `packages/client`
publishes only `dist`, `README.md` and `CHANGELOG.md`, so its test-file
comment ships nothing. The `packages.list` paragraph does ship: measured
on the rebuilt client `dist` at `b9b0d32d2f`, the new phrase is in
`index.d.ts`, `index.d.mts`, `index.js` and `index.mjs`. 'Tracked as
objectstack-ai#19324' and `stack.zod.ts:1283` are in 0 files, and the lit control, the
unchanged `Array.isArray` warning, is in the same 4 files. So the
changeset also carries `@objectstack/client`: patch (`b9b0d32d2f`).

## Verification

The head is `b9b0d32d2f`. `packages/spec` did not move between
`17f1e3d41c` and `b9b0d32d2f` (`git diff` on it is empty), so the spec
readings below, taken at `17f1e3d41c`, hold at the head. The client and
gate readings were re-taken at `b9b0d32d2f`.


- **`@objectstack/spec` build:** exit 0. `pnpm --filter
@objectstack/spec check:generated`: all 15 generated artifacts up to
date.
- **`@objectstack/spec` typecheck:** exit 0 (`tsc`, scripts and test
layer).
- **`@objectstack/spec` tests, targeted:**
- 16 files / 613 tests in `--project local` and 7 files / 99 tests in
`--project repo`, all passed.
- The set: every test on the edited declarations (`package-api`,
`stack-json-stage-package-body`, `assembled-package-body`,
`api-entry-graph.pin`, `split-entries`), the tests that read
`stack.zod.ts` as text, and every spec test that walks and reads source
files.
- ⊘ NOT MEASURED locally: the full spec suite. It passed the 560 s
foreground ceiling with no verdict (exit 124). CI runs it.
- **`@objectstack/client`** (at `b9b0d32d2f`): build exit 0
(`check-dts-emitted` 1/1).
- typecheck exit 0, with `check:test-typecheck` OK at 0 files / 0 errors
/ 0 pinned. Its test program compiles `return-type-precision.test.ts`
(`--listFiles`: 1 hit, against `spec/dist/api-assembled`).
  - tests: 50 files / 635 passed.
- **Cross-package tests on the assembled row:** `runtime`
`packages-read-delete-response-conformance` 17 passed, `objectql`
`registry-package-manifest-serializable` 16 passed.
- **`node scripts/pm/dispatch-gates.mjs --ran`** (re-derived and re-run
at `b9b0d32d2f`; the new path added no family): 85 families derived. 83
run, every one exit 0.
- ⊘ NOT MEASURED: `check:dual-build-cjs-loads`, which exits 3 until
every workspace package is built.
- ⊘ UNRUN: `check:type-check-debt`, which re-measures `tsc` per ledger
entry over the whole built workspace. The only test-file edit is comment
text, and the client test layer holds 0 errors.
  - CI runs both.
- **ESLint, narrowed to the 4 changed `.ts` files** (at `b9b0d32d2f`):
- Scope: `--print-config` applies 6 rules to `index.ts` and 5 to each of
the other three. `--format json` reports 4 files, 0 errors, 0 warnings.
- Why untouched files cannot change: `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`), so this diff cannot
move the verdict on any other file.

## Acceptance notes

- **`.changeset/17536-client-packages-read-doors-either-stage.md` still
calls the gap "tracked as objectstack-ai#19324" and cites `stack.zod.ts:1283`.**
- It is a foreign changeset, and `check-empty-changeset` rule 2 forbids
editing it.
- This PR's changeset states the settled reading instead, for the same
release text.
- **Not merged with `main`.** `origin/main` is at `560b724c95`, 10
commits past the base, and none touches the five changed paths (empty
`git diff --stat` on them). CI validates the merge ref.

_Body corrected by `domain:spec` seat 2 after patch round 2
(`b9b0d32d2f`), from the dev's reported deviations: the client
paragraph, the client changeset line, the stale Acceptance note removed,
and the verification anchor._

---
_Generated by [Claude
Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

3 participants