fix(spec,objectql): declare the inert-JSON artifact and registry-record package body stages, and stop the record under-reporting functions - #19373
Conversation
…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>
…n reporting 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>
…SON stage work Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…sembled-package-body-inert-json
…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>
…sembled-package-body-inert-json
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
…bled-package-body-inert-json
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>
Contract reviewServed-tier: ① Derived judgmentsGoverning 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 What What changes. Is Do the two stage schemas admit what OPEN QUESTION 1 — admitting both lowered spellings. Faithful, and I would have failed the other reading. The ruling's phrase 「 Does the The two ruling items read rather than transcribed, my verdict on each. (a) One class of row moves from tolerated to refused-by-declaration: a map entry whose ② Semver levelThe written rule (
③ Boundary flags
Measurement notes. The two new schemas carry the structural annotation Implemented-by: 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>
Contract reviewServed-tier: Scope: the DELTA between the previously PASSed head ① Derived judgmentsThe 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. Finding ① — the second deferred os-regen path. Verified, and the drop was real: at the merge commit the 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 ( 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) Both sides' load-bearing names, source AND built dist. Source and ledgers at head: 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 ( CI on this sha: 42 check runs, 38 success, 4 skipped, 0 other. ② Semver levelThe written rule (
Both changeset files are byte-identical between ③ Boundary flags
Measurement notes. Type annotations are written here with PARENTHESES in place of angle brackets: the two new schemas carry Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance — carriers cleared, landingBoth 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
Record consumed: comment 5772860458 on this PR — Tier verified from the reviewing round's own transcript, ⛔ not from the dispatch parameter — counting the harness-stamped per-message served-model field:
The record was adopted verbatim; ⛔ nothing in it was edited. One number the review corrected, and this seat had propagatedThe merge round's prose said main removed the
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
The Tier H gate is lifted, by the register's own terms. Two
⇒ both approvals sitting on
|
…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>
… 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>
…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>
…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>
…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>
…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>
…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>
…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>
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 confidence gap the ruling asked me to close first
「whether
effectis required or defaulted on the declaration schema — read it, ⛔ do not mint a value」Defaulted.
FlowFunctionDeclarationSchema.effectisFlowFunctionEffectSchema.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 offunctionsstatesFlowFunctionEffectSchema.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_EFFECTand the array form gets nothing.What landed
packages/spec/src/automation/flow-function.zod.ts—FlowFunctionLoweredDeclarationSchemais exported (step 1), with itsFlowFunctionLoweredDeclaration/…Parsedaliases. It was a module-localconst, andautomation/index.ts'sexport *only re-exports what is already exported.packages/spec/src/stack.zod.ts— two new bodies besideAssembledPackageBodySchema:ArtifactStagePackageBodySchema— the on-disk artifact stage.functionsentries are the lowered spellings,hooks[].handleris a string.RecordStagePackageBodySchema— the registry record stage: literallyArtifactStagePackageBodySchema.extend({ functions: … })withfunctions[].handleroptional in both the map-record form and the array form, and nothing else.AssembledPackageBodySchema,composeStacksand thecannot driftinvariant 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 structuralz.ZodTypeannotation as the assembled body, for the two reasons recorded there (TS7056; a named alias turningstack.zodinto a shared chunk).packages/spec/src/api/package-api.zod.ts— the installed-package row'smanifestis rebound to the record stage (step 1). Thez.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 whyArtifactPackageSchemaandObjectStackDefinitionSchemapublish no JSON Schema —src/stack.zod.tsis not one of the subpath namespacesbuild-schemas.tswalks, so neither is ever reached by the emit loop.packages/objectql/src/registry.ts— step 2.withDeclaredFunctionEntriesrewrites a bare callablefunctionsmap entry to{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }at the assembly boundary, beforetoRecordManifestruns.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
functionsentries the lowered declaration」 is implemented as BOTH lowered members ofFlowFunctionEntrySchema, not only the record one.objectstack buildemits{ 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.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.tspins 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
z.toJSONSchema(self-test over the whole body)Function types cannot be represented in JSON Schema); probe controls litz.string()YES, darkz.object({a: z.function()})NOconfig.ts:244-249) reports 2 functions on theGET /packagesrow, the bare one as a handler-less declaration{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}, driven through the realSchemaRegistry.installPackagehooksunchangedfunctionsform also keeps its entry:[{"name":"syncBilling","effect":"writes"}]AssembledPackageBodySchema/composeStacks/ the invariant untouchedassembled-package-body.test.tsandcompose-stacks-manifest-preserve.test.tsstay greennoted, not filedcorrections in the same editautomation/FlowFunctionLoweredDeclarationis now injson-schema.manifest/automation.json, so 「the lowered record … publishes normally」 is now a fact.package-api.zod.tsdocblock last sentence: corrected in place, see aboveStage 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 fromunknownto a declaration, and nothing else moved.Reverse verification — two ablations, each restored with proof
Both ran against committed code, each with a
traprestore, an on-disk landing proof (anchorgrep -cbefore/after plus a blob-hash change) and a restore proof (git hash-objectback to the HEAD blob,git diff HEADempty).toRecordManifest(withDeclaredFunctionEntries(manifest))→toRecordManifest(manifest); anchor 1→0, injected 1, blobb0af60d7…→17b7c93c…):registry-package-manifest-serializable.test.tsgoes 1 failed / 15 passed, naming the exact defect —expected [ 'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1) ]. Restored blobb0af60d7…, diff empty.jsonStageFunctionsKey(true)→(false); anchor 1→0, injected 2, blob60c13b43…→822bed8e…): 2 failed / 79 passed across two files —record accepts the handler-less declaration; ⛔ the ARTIFACT stage refuses itandparses 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 blob60c13b43…, 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.shwithOS_VERIFY_LOCK_SLOT=issue-17518, verdicts read from the wrapper's ownVERDICT command-exitline 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 changespnpm --filter @objectstack/objectql test— 303 files / 5057 tests passedpnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2over the package-door / artifact population, enumerated by a name match onpackages/runtimeforpackageorartifactso the population is reproducible — 39 files / 512 tests passed.pnpm execruns 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).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 buildexit 0 (34/34 declared.d.tspresent,check-dts-referencesresolved 378/378), and the whole@objectstack/runtimedependency 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/objectstackderived 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 ownPREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured(66 packages have nodist; 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 unbuiltpackages/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-declarationsis gone (retired upstream by #19024 mid-round) andcheck:gitlink-declaredis 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 -naPover 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 owngen:scripts.authorable-defaultsrecordsautomation/FlowFunctionLoweredDeclaration:effect = "pure", which is the confidence-gap reading in ledger form.packages/spec/dropped-refinements.baseline.json— fourapi/*entries each gain one site (…manifest.hooks.element.object), counts 569 → 573. Cause: the record stage declareshookswherez.unknown()declared nothing, soHookSchema'sobjectrefinement 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 listingstack.zod.ts's exports.skills/**readings, and the landing tierThis 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:SKILL.md): 6145 before, 6145 after — net 0.node scripts/check-skills-token-ratchet.mjsexits 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 fromz.unknown()onfunctions/hooksto 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 /packagesreports 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.tsresolves to #19315; DARK controlpackages/spec/src/zzz-no-such.zod.tsresolves to nothing.packages/spec/src/automation/flow-function.zod.ts,packages/spec/src/api/package-api.zod.ts,packages/objectql/src/registry.ts— free.packages/spec/src/stack.zod.ts— held by docs(spec): scope the email-template locale-floor claims to a call that names a locale #18482, spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147, spec: declaresCollection reads a pipe's authorable side, so a preprocess-wrapped collection key cannot silently leave the merge refusal set (#19150) #19314, all below A′'s region. spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 landed during this round and merged cleanly here (itsstack.zod.tshunk is a comment).packages/spec/dropped-refinements.baseline.json— also written by spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 (landed, resolved here) and by the still-open feat(spec)!: publish the $-prefix key ban the normalized filter enforces, and make the ratchet able to see it #19335, which rewrites the samemeasuredheader and adds entries. That is a line-level contention on a ledger whose correct value is recomputable: whoever lands second re-runspnpm --filter @objectstack/spec buildand re-applies the delta it prints. ⛔ Not a semantic collision.origin/mainhas been merged six times on this branch.#19024(which retiredapi-surface-declarations/) came in early, which is why noapi-surface-declarations/*.txtappears 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.shwas NOT used in either round — itsrerunarm 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 anorigin/mainfetched 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:
content/docs/references/index.mdxmerge=os-regen6290447bd9a== ours, != theirs7e1f9b6f13e)content/docs/references/api/package-api.mdxmerge=os-regen988bedaa480== ours, != theirsd09cd420711)packages/spec/dropped-refinements.baseline.jsonmeasuredheaderpackage-api.mdxappears 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 whatos-regen-merge.shstep 2 specifies and what the driver's own$GIT_DIR/os-regen-pendingrecord listed.The regenerated docs are the UNION, proven in both directions (added/removed line multisets compared as sets):
package-api.mdxidentical at 20 and 14 lines;index.mdxidentical 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 whatgen:schemaitself reports for the merged sources. Main broughtDatasetSelection,DatasetCompareToandDatasetTotalsand retiredKernelSecurityScanResult/KernelSecurityVulnerability; this branch broughtFlowFunctionLoweredDeclaration. 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.⚠️ 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
dropped-refinements.baseline.jsonis hand-edited BY DESIGN with nogen: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/DatasetSelectionarrived from main via #19638 and survives; main's removal of thefields.out.keyTypesites 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).gen:schemaadjudicated and measured 565 dropped sites across 205 published schemas — the union as resolved. One counter the build corrected:refinementSitesThatDidProjectread 357 and the build measures 366.measuredcounters 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
packages/spec/api-surface-declarations/ui.txtproduced 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: commit24d622b94b8, a spec change touching zero files underpackages/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.packages/objectql's tests resolve@objectstack/metadata-protocolfromdist, so after merging upstream The in-processinstallPackagedoor ignores theenableOnInstallits 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 inprotocol-install-package-enable-on-install.test.tsfailed 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, andcheck:test-source-aliasalready 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