spec: declaresCollection reads a pipe's authorable side, so a preprocess-wrapped collection key cannot silently leave the merge refusal set (#19150) - #19314
Conversation
`declaresCollection` read only `def.in` on its `pipe` arm. `z.preprocess(fn, schema)` puts the transform stage in `in` and the validated schema in `out` — the opposite of `a.transform(fn)` — so a preprocess-wrapped collection key resolved to a `transform` node, fell through to `default: return false`, and silently left the refusal set `objectCollectionKeys()` derives for `objectConflict: 'merge'`. Reads OUT only when IN is a transform stage: the rule four sibling walkers already run, and not `in || out`, which would pull a key whose authored value is a scalar into the refusal set. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
A preprocess-wrapped collection key on `ObjectSchema.shape` is the shape the real production walk cannot see today, so the probe keys ride on that shape through `vi.mock` and the legs are read through `composeStacks` itself: bright control (the IN-only reading still answers "not a collection"), main (the key is now enumerated in the refusal), dark control (a genuine `.pipe()` authored as a scalar stays out — the leg that discriminates the landed rule from `in || out`), plus a today-invariance block asserting all three candidate readings derive the same set on the unmocked shape. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 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 dccb32785cca2fc9dd713017557ef784177b925d && git checkout dccb32785cca2fc9dd713017557ef784177b925d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 7d67e1ee4136aee8f8e6ea838950c7fb71520be2 && git checkout -B drift-repro e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 && git merge --no-ff 7d67e1ee4136aee8f8e6ea838950c7fb71520be2
node scripts/docs-audit/affected-docs.mjs --json e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 |
The seat ruled arm B on the push-back: the measurement governs. Published behaviour does not move by one row — 0 of 43 ObjectSchema top-level key verdicts change, the derived refusal set is identical, and no export is added or removed — so `yes` was over-declared and the bump is a patch. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Independent, adversarial re-derivation. Every reading below was taken first-hand on two fresh detached worktrees — head ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing provenance — contract review PASS, carriers already clear, 2026-09-20T14:32ZThe at-tier contract review of this PR is on the record as comment Independence pair, machine-readable on the record:
Distinct kinds, so no SELF-REVIEW is reported. The reviewer was fed the card, this PR and the 10:59Z seat ruling as inputs to judge, explicitly not as rules to enforce, and ⛔ never this seat's conclusions. Carriers. Neither this PR nor card #19150 carries
|
Correction to
|
| blob | value |
|---|---|
local scripts/pm/check-widening-tells.mjs |
032bd9db74cb5a68afcbc37415785944a6e95161 |
origin/main:scripts/pm/check-widening-tells.mjs |
6d2ba5a70fce24573fc2dfb6ca721bf72f25ab17 |
Different files — and d9282a4bd7 (#19153) is precisely a +337/−27 change to that file. So the readings were taken with the pre-fix matcher. ⛔ A reading from an instrument that is not the current one is not a reading of the current board, whatever it says.
The checkout has been fast-forwarded (git merge --ff-only origin/main, exit 0 — this branch carried zero unique commits, so nothing was lost), and the local blob now equals main's.
2. Re-measured with the current instrument, the refusal survives — and its owner changes
check-clause2-carriers.mjs --pair 19314 at 2026-09-20T19:50Z: exit 4, C5, seven rows, all still T2 at packages/spec/src/stack.zod.ts:3412–:3418. Unchanged in count and in line.
That matters because 5750435676 said the repair belongs to card #19099. It does not. #19099's PR landed as d9282a4bd7 at 2026-09-20T18:52:57Z, and its subject names the leg it fixed:
fix(pm): check-widening-tells reads the member's PRIOR schema — a bound inside a previously-
z.unknown()bag is not a new key (#19153)
That is the T1 leg. The seven tells refusing this PR are the T2 leg — memberTellKind returning T2 for any bare string element on a contract-source line without requiring it to sit inside a closed set. Two different legs of one file; #19099's landing does not touch this one.
So the T2 repair is still owed and still uncarded, and it is a distinct fix from the one that just landed — not, as this seat wrote, a thing that #19099 would clear on its way past. This PR remains READY, clean, all checks green, with an at-tier contract review of record at PASS (5750417684), blocked solely by a refusal its own review ruled false on all seven rows.
⛔ The remedy is unchanged and none of it is a yes flip or a reformat: repair the matcher's T2 leg with a --self-test case pinning the shape. This session cannot create the card (see #19362).
Generated by Claude Code
|
Pointer from the director seat ( Generated by Claude Code |
Landing provenance — cleared by a rule change, ⛔ not by a seat overruling the gate, 2026-09-21T05:03ZThis PR was READY, What was blocking it
⛔ The seat did not resolve this by declaring What changed, and by whose wordMaintainer ruling of 2026-09-21, verbatim:
and, on decision card #19491:
Item 2 of those six demoted C5 from a refusal to a report. That shipped as PR #19495, merged The clearing reading, taken from main AFTER that merge⛔ Not adopted from #19495's report; re-run by this seat on
⭐ This PR clears because C5 stopped refusing, ⛔ not because a tell disappeared. Report-only is not a clearance: nothing above says this diff does not widen. What rules on those seven tells is the at-tier review, and its record — PASS, with the tells examined one by one — is the reason this seat is content to land it. ⛔ This seat has not approved this PR and will not. Armed through the merge queue at 2026-09-21T05:03:26Z; ⛔ never merged outside it. Generated by Claude Code |
…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>
…stack-ai#19495) Fixes objectstack-ai#19490 ## What this changes `scripts/pm/check-clause2-carriers.mjs`'s **C5** limb turned a widening **tell** into a hard refusal: a card declaring `Clause-②: no` whose diff carried tells made `--pair N` exit 4, and PD objectstack-ai#14 makes `--pair` at 0 a conjunct of the landing predicate. C5 is now **report-only** — the row still prints, with its `file:line` evidence and its remedy text, and it no longer contributes to a non-zero exit. **⛔ C5 and nothing else.** C6 stays a hard refusal, as do C2, C3, C8, C9 and the UNJUDGED exit-2 path. ## The ruling this stands on Maintainer ruling of 2026-09-21, recorded at objectstack-ai#18917 (comment). Quoted verbatim, untranslated, because paraphrasing a ruling rewrites it: > 阻碍我的pr落地,导致 agent 开发满就是负面因素,哪怕挡住几个bug,但是出现bug也是可以重新修改的。 The operative distinction that record draws, and which the code now carries in its own words (`pairReportRows`'s docblock): > A limb that judges 「is this diff widening a contract」 — which `check-widening-tells` itself says it cannot prove (its header: 「a tell, never a proof」, 「cannot tell an array element from a call argument」) — does not hold a hard gate. A limb that asks 「does a review of record exist」 does. ## The mechanism - `pairReportRows(pair, repo)` is new and is **where the demotion is**: the C5 row is built there instead of being pushed into `rows`, and `rows.length === 0` is the exit-0 condition. A row that is not in that list cannot refuse. - `renderPair` is split into the pure **`pairRendering`** (every line, its stream, and the exit, computed before a byte is printed) and a printer. That split is what lets the self-test pin what a run **prints** beside what it **exits** — "the row still prints" and "the exit is 0" are two claims, and a case pinning only the second stays green over a row that has gone silent. - The C5 row's text gains `C5_REPORT_ONLY_DISPOSITION`: it says the row is report-only, that the at-tier contract review is what rules on the tell, and ⛔ that report-only is **not** a clearance and **not** a verdict that the tell is false. `REFUSAL_SENTENCE`'s remedy is carried verbatim, unchanged. - The report row prints under `⚑`, never `✗` — a refusal marker beside an exit of 0 is the exit register's banned "0-with-a-message", inverted. - `greenPairLine` gains a `wideningReported` branch, so an exit-0 line on a pair that carries tells says so instead of going quiet. Without it a 0 would read as "narrow", which is the silence this file exists against. - The report and its `file:line` list print on **every** exit this path can answer — including the exit-2 gap path, where the tell would otherwise be lost. ## Measured, before and after Exit codes captured BEFORE any pipe. | run | before | after | | --- | --- | --- | | `--pair 19314` | **exit 4**, sole adverse row `✗ C5`, 7 tells | **exit 0**, row `⚑ C5`, the same 7 tells | | `--pair 19438` | **exit 4**, row `✗ C6` | **exit 4**, row `✗ C6` — untouched | Both halves of requirement 1 verified: 19314 reaches 0 **because C5 no longer refuses**, not because any tell disappeared. `grep -c 'T2 packages/spec/src/stack.zod.ts'` reads **7** on the before log and **7** on the after log — `stack.zod.ts:3412` through `:3418`, each with its `T2` explanation and its `+` source line, in both. The after run's exit-0 line says so itself, rather than reading clean: >⚠️ Its diff DOES carry widening tell(s), printed in full above with their file:line evidence: since the maintainer ruling of 2026-09-21 row C5 is REPORT-ONLY and moves no exit code, so this 0 says the clause-② limbs are LEGIBLE and ⛔ does NOT say the diff is narrow. The at-tier contract review is what rules on those tells. ## Pins, and the ablation for each A new self-test battery, `⭐ the 2026-09-21 ruling: C5 REPORTS its tell, C6 still REFUSES`, 24 cases, declared in `SELF_TEST_BATTERIES` at its measured floor. Self-test: **1075 → 1099 cases, exit 0**. Six ablations, each through `scripts/ablation-replace.mjs` so the mutation's landing is the tool's own verdict (anchor count 1 to 0, blob hash before and after) and the restore is proved by `blob == HEAD` plus an empty `git diff HEAD`: | # | ablation | red cases | what it proves is not vacuous | | --- | --- | --- | --- | | 1 | put C5 back into `rows` | 6 | the exit-0 half: "C5 present reaches 0" | | 2 | delete the `printReports` body | 9 | the printed half: row, `file:line`, evidence list, remedy, disposition, marker | | 3 | delete `greenPairLine`'s `wideningReported` branch | 1 | the exit-0 line says the diff carries tells | | 4 | delete the `C6` row push in `pairRows` | 14 | C6 still refuses, and the mixed case's 4 is C6's | | 5 | make `pairReportRows` return `[]` | 8 | the report exists at all, including the structural case | | 6 | make `wideningUnjudged` return `null` | 4 | the UNJUDGED exit-2 path is untouched | Every ablation restored byte-identically before the next; `git diff HEAD` empty and `git status --porcelain` clean after each. The two `⛔ CONTROL` cases on the clean-diff pair stay green under all six by design — they are the non-vacuity bracket for the reported/clean pair of readings, not pins on code. ## Gates `node scripts/pm/check-governed-merges.mjs --test scripts/pm/check-clause2-carriers.mjs` → **exit 0, NOT governed** — ordinary queue landing applies to a PR with exactly this file list. Size: 292 changed lines (+260 / -32), under the 5000-line human-merge threshold. Every command's exit code captured BEFORE any pipe: - `node scripts/pm/check-clause2-carriers.mjs --self-test` → 0 (1099 cases) - `node --check scripts/pm/check-clause2-carriers.mjs` → 0 - `pnpm exec eslint scripts/pm/check-clause2-carriers.mjs` → 0 - `node scripts/check-self-test-wired.mjs` → 0 - `node scripts/check-scripts-symbol-anchors.mjs` → 0 - `node scripts/pm/check-governed-merges.mjs --test` → 0 - plus the full family list derived by `node scripts/pm/dispatch-gates.mjs --commands` (35 commands, derived from the merge base, three-dot) — all 0. ## 维护者速读(草稿) **改了什么** — 把全仓落地谓词里的 C5 一肢从「拒绝」降为「只报告」。一张卡声明 `Clause-②: no`、而 diff 带了扩面迹象时,这条行照旧打印,连同它的 `file:line` 证据和补救文字;但它不再让 `--pair` 返回非零。C6(本轮是否留下达档复核记录)以及 C2/C3/C8/C9、UNJUDGED 的 exit 2 一律原样不动。 **为什么改** — 2026-09-21 的裁决:挡住 PR 落地、让开发 agent 排满,本身就是负面因素,哪怕代价是漏几个 bug,因为 bug 还能再改。裁决给出的分界是问题的**种类**而非严重程度:判断「这个 diff 是不是在扩接口」的肢,其工具自己写着「只是迹象,不是证明」「分不清数组元素和调用实参」,就不该持硬闸;问「有没有一条复核记录」的肢可以。现场证据:`check-widening-tells.mjs` 14 天改了 18 次,挂着 5 张缺陷卡且**全部是误报**;PR objectstack-ai#19314 全绿、达档复核 PASS 已判定它那 7 条迹象**全部为假**,仍被这 7 条卡了 12 小时。 **风险与代价(含回滚)** — 代价是真实的:一个确实扩了接口却声明 `no` 的 PR,机器不再拦它,改由达档复核去判——这正是裁决接受的那笔交易。风险被三处收窄:行照打不误、证据照列、exit-0 那行自己写明「本 0 不代表 diff 是窄的」。回滚成本一行:把 `pairRendering` 里的 `const reports = pairReportRows(pair, repo)` 改回推进 `rows`,6 条用例会立刻转红提示。本 PR 保持 draft,不合并、不入队、不动标签,等维护者看过。 **席位意见** — **你要做的** — 读一眼上面的分界是否就是你的本意(C5 报告、C6 仍拒);同意就把这个 PR 标 ready 并走队列。合并后 `--pair 19314` 立刻读 0,那张被挡 12 小时的 PR 就能合法落地,不需要任何人自查放行。 ## Acceptance notes - **No card existed for this when the work started; this session created objectstack-ai#19490 itself.** The brief that dispatched it predicted issue creation would be refused. It was not: `POST /repos/objectstack-ai/objectstack/issues` returned 201. The branch therefore carries a real `issue-NNNNN` segment and `CLAIM_BRANCH_SHAPE` is satisfied — the predicted exit-2 UNJUDGED consequence does not apply. - **`--pair` on THIS PR is adverse on C2, and deliberately so.** Card objectstack-ai#19490 carries no `Claim:` comment, because writing one is the owning seat's act and not a dispatched executor's. A seat that wants that reading to clear posts the claim naming this branch. `scripts/pm/**` is not a governed surface, so PD objectstack-ai#14's `--pair`-at-0 conjunct does not gate this PR's landing either way. - No labels were added or removed, per the brief. `scripts/pm/**` is on the changeset fast track (not published), so `skip-changeset` applies on the merits; this PR does not hang that label. - The demotion broke **no existing self-test case**: the full suite was green on the edited file before a single new case was added. The old C5-refuses behaviour was pinned nowhere at the rendering level, which is itself worth knowing about this file. - `check-widening-tells.mjs`, `check-half-states.mjs`, `check-governed-merges.mjs`, `AGENTS.md` and all skill text are untouched. The tell is not the defect being fixed here — its force is. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ Co-authored-by: Claude <noreply@anthropic.com>
…hange (objectstack-ai#19498) (objectstack-ai#19511) Fixes objectstack-ai#19498 Clause-②: no Gate weakening is a maintainer floor. The sentence that authorizes this one, verbatim (ruling objectstack-ai#208 on objectstack-ai#19491, part R4): > 19491 接受你的建议,并立刻派发处理相关任务。 ## What this changes `Lint & Repo Gates` set the wall clock of PR objectstack-ai#19314's CI — 27.4 minutes over 184 steps for a three-file `packages/spec` diff, above the longest test shard — and 18.6 of those minutes were the tooling's own self-tests, corpora and censuses. The single `PM dispatch-gates self-test` step was 11.8 of them, on a PR that changes no PM tool: that family's read-set included the whole-tree censuses its battery runs — the content of every JS/TS and `.sh` file, the nested `.gitignore` files, and the tracked NAME set, which made an ADDED path anywhere run it. 1. **`pm_dispatch_gates` is narrowed to the tool's own inputs**: the workflow tree and composite actions its discovery reads, every gate source it resolves under `scripts/` and a workspace package's own `scripts/`, the `package.json` that names a `check:*` script, the agent configuration its live cases read (`.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md`), and root configuration. The self-test's own refusal semantics are untouched — an unreadable population still refuses. 2. **Four more tooling steps go behind the selector**, each as a family with a read-set of its own inputs plus the matching `if:` line in `lint.yml`. 3. **`push` to main and the hourly scheduled run are unchanged** and keep the whole battery: the selector runs everything for any event it does not scope. ## The census: every unconditional step at or above ~0.3 min Measured on run 35506407130, job `Lint & Repo Gates` (check-run 106066910262) at head `7d67e1ee4136aee8f8e6ea838950c7fb71520be2`, read step by step from `GET /repos/{owner}/{repo}/actions/jobs/106066910262`. 184 steps, 27.4 min. | step | min | subject | disposition | |:--|--:|:--|:--| | PM dispatch-gates self-test | 11.80 | tooling self-test | already scoped — **read-set narrowed** | | Engine query-options erasure ratchet | 2.07 | product ratchet | unchanged (scoped by its real read-set) | | ESLint | 1.27 | product lint | stays unconditional (objectstack-ai#16496 card ruling 2) | | Slot-lookup ratchet | 1.07 | product ratchet | unchanged (scoped by its real read-set) | | Comment mask agrees with a real parser over the whole corpus | 0.90 | corpus agreement | unchanged (already a family) | | scripts/ entry guards go through one predicate | 0.62 | tooling corpus over `scripts/**` | **moved** → `entry_guard` | | Engine test-double contract gate | 0.50 | product gate | stays unconditional | | Self-test workflow-command gate | 0.48 | tooling self-test | **moved** → `self_test_workflow_commands` | | A declared gate population reaches the tree | 0.40 | tooling gate over the derivation | **moved** → `declared_population_live` | | Checkout repository | 0.40 | runner infrastructure | not a gate | | ADR anchors + number uniqueness | 0.37 | docs/ADR gate | stays unconditional | | Tenant-audit census matches the tree | 0.35 | product census | stays unconditional (named in the card) | | PM bare-root worklist self-test | 0.32 | tooling self-test | **moved** → `bare_root_worklist` | Below the line, left unconditional because the list decides and not the principle: `scripts/ shared-module self-tests` 0.27, `Cross-package test inputs` 0.25, `Declared registry log level` 0.25, `Platform-object tenancy census` 0.20, `Merge-driver wiring gate` 0.20, `Documented HTTP status matches the status the runtime emits` 0.17, `ObjectQL double limit gate` 0.17, `Changeset-family gate self-tests` 0.15. Every other step in the job measured under 0.17 min. Product ratchets and censuses stay unconditional throughout: `query_options_erasure`, `slot_lookup`, the tenancy and tenant-audit censuses, the engine gates. ## The four families added, and what each reads | family | step command | read-set | |:--|:--|:--| | `entry_guard` | `pnpm check:entry-guard` | `scripts/**` — its own `ROOT_DIR_WATCH_HINTS`, held against the root it walks by its own self-test | | `declared_population_live` | `pnpm check:declared-population-live` | imports `discoverFamilies` + `trackedFiles`: the workflow tree, every gate source discovery resolves, the tracked NAME set (only a name that DISAPPEARS moves its verdict, and deletions already run everything) | | `bare_root_worklist` | `node scripts/pm/bare-root-worklist.mjs` (self-test only) | the same derivation, the same read-set | | `self_test_workflow_commands` | `node scripts/check-self-test-workflow-commands.mjs` | its declared `scripts/**` population of `.mjs`, `.mts` and `.sh` files, plus the workflow tree and `.github/actions` it discovers the runnable self-tests from | ## What is weaker now, said out loud A ratchet's skip says: no changed path is one this family reads. A tooling self-test's skip now says something weaker: no changed path is one the **tool's own inputs** name, while the battery behind it may still read that path through a whole-tree census. So a defect these five would have caught can first appear on `main` instead of on the PR that wrote it. Three things bound that, and none of them changed here: every doubt still runs everything (unresolvable base, empty diff, unclassified path, any deletion, rename or type change); `push` on main and the hourly `schedule` run the whole battery; and widening the skip further is again a maintainer call. The selector's header carries this paragraph beside the read-sets, and `lint.yml` carries it on the steps themselves — including the three steps whose prose used to say "unconditional, like every self-test around it", which this change would otherwise have made false. Two previously pinned cases are given up deliberately and are now pinned in the other direction, so the loss is legible: an ADDED file anywhere no longer runs `pm_dispatch_gates` (the tracked-NAME sweep), and neither does a nested `.gitignore` or a workspace `.sh` outside `scripts/` (the objectstack-ai#16769 case). ## Acceptance — the four dry runs, verbatim **(a) `pull_request`, changed files = PR objectstack-ai#19314's list.** Reproduced in a throwaway detached worktree off `origin/main` (`c9b23cd066`), driven with this branch's selector; the worktree was removed afterwards and nothing under `scripts/pm/` is touched by this PR. ```text -- (a) changed files -- A .changeset/19150-declares-collection-pipe-authorable-side.md A packages/spec/src/compose-stacks-collection-pipe-arm.test.ts M packages/spec/src/stack.zod.ts Gate-family diff base: c9b23cd (merge-base of origin/main and HEAD) Gate families: 3 run, 6 skipped (event: pull_request; changed paths: 3) run slot_lookup reads packages/spec/src/compose-stacks-collection-pipe-arm.test.ts (A, workspace) run query_options_erasure reads packages/spec/src/compose-stacks-collection-pipe-arm.test.ts (A, workspace) skip entry_guard no changed path is in its read-set run comment_mask_corpus reads packages/spec/src/compose-stacks-collection-pipe-arm.test.ts (A, workspace) skip pm_dispatch_gates no changed path is in its read-set skip declared_population_live no changed path is in its read-set skip bare_root_worklist no changed path is in its read-set skip self_test_workflow_commands no changed path is in its read-set skip verify_lock no changed path is in its read-set VERDICT command-exit=0 ``` **(b) `pull_request`, changed file `scripts/pm/dispatch-gates.mjs`** — same throwaway worktree: ```text -- (b) changed files -- M scripts/pm/dispatch-gates.mjs Gate-family diff base: c9b23cd (merge-base of origin/main and HEAD) Gate families: 6 run, 3 skipped (event: pull_request; changed paths: 1) skip slot_lookup no changed path is in its read-set skip query_options_erasure no changed path is in its read-set run entry_guard reads scripts/pm/dispatch-gates.mjs (M, scripts) run comment_mask_corpus reads scripts/pm/dispatch-gates.mjs (M, scripts) run pm_dispatch_gates reads scripts/pm/dispatch-gates.mjs (M, scripts) run declared_population_live reads scripts/pm/dispatch-gates.mjs (M, scripts) run bare_root_worklist reads scripts/pm/dispatch-gates.mjs (M, scripts) run self_test_workflow_commands reads scripts/pm/dispatch-gates.mjs (M, scripts) skip verify_lock no changed path is in its read-set VERDICT command-exit=0 ``` **(c) `push` — every family runs:** ```text Gate families: 9 run, 0 skipped (event: push; changed paths: 0) run slot_lookup event 'push' is not scoped -- the full battery runs run query_options_erasure event 'push' is not scoped -- the full battery runs run entry_guard event 'push' is not scoped -- the full battery runs run comment_mask_corpus event 'push' is not scoped -- the full battery runs run pm_dispatch_gates event 'push' is not scoped -- the full battery runs run declared_population_live event 'push' is not scoped -- the full battery runs run bare_root_worklist event 'push' is not scoped -- the full battery runs run self_test_workflow_commands event 'push' is not scoped -- the full battery runs run verify_lock event 'push' is not scoped -- the full battery runs VERDICT command-exit=0 ``` **(d) the selector's self-test** (`pnpm check:select-gate-families`), which also pins the YAML half against the real `lint.yml`: ```text ok the workflow scopes exactly the families the script decides (9) ok each family gates exactly one step ok pm_dispatch_gates gates the step running: pnpm check:pm-dispatch-gates ok query_options_erasure gates the step running: pnpm check:query-options-erasure ok slot_lookup gates the step running: pnpm check:slot-lookup ok entry_guard gates the step running: pnpm check:entry-guard ok declared_population_live gates the step running: pnpm check:declared-population-live ok bare_root_worklist gates the step running: node scripts/pm/bare-root-worklist.mjs ok self_test_workflow_commands gates the step running: node scripts/check-self-test-workflow-commands.mjs ok verify_lock gates the step running: bash scripts/pm/os-verify-lock.sh ok comment_mask_corpus gates the step running: node scripts/check-comment-mask-corpus.mjs all 44 cases passed (228 checks) VERDICT command-exit=0 ``` The battery grew from 30 cases / 120 checks at its floor to 44 / 228, and the floor moves with it (42 / 220). New cases: the nine ids in job order, an ADDED path inside a read-set, a composite action, the PR objectstack-ai#19314 shape under both `merge_group` and `pull_request`, and one `pin_step` per new family. For completeness, this PR's own diff under `pull_request` — a change to the selector and the workflow runs all five tooling families: ```text Gate-family diff base: 2cac363 (merge-base of origin/main and HEAD) Gate families: 5 run, 4 skipped (event: pull_request; changed paths: 3) skip slot_lookup no changed path is in its read-set skip query_options_erasure no changed path is in its read-set run entry_guard reads scripts/ci/select-gate-families.selftest.sh (M, scripts) skip comment_mask_corpus no changed path is in its read-set run pm_dispatch_gates reads .github/workflows/lint.yml (M, workflow) run declared_population_live reads .github/workflows/lint.yml (M, workflow) run bare_root_worklist reads .github/workflows/lint.yml (M, workflow) run self_test_workflow_commands reads .github/workflows/lint.yml (M, workflow) skip verify_lock no changed path is in its read-set VERDICT command-exit=0 ``` ## Expected wall clock The five steps carry 13.62 min of the 27.4-min job (11.80 + 0.62 + 0.48 + 0.40 + 0.32). On a diff of objectstack-ai#19314's shape all five skip and nothing else changes, so `Lint & Repo Gates` should read about **13.8 min** — arithmetic on one run's step timings, on that runner with its cache state, not a prediction of the next run. The card's bar is ≤ 16 min, and the director seat measures the real number on the first product PR after this lands. ## Tier S — not on the governed register ```text $ node scripts/pm/check-governed-merges.mjs --branch claude/issue-19498-self-tests-off-pr-path derived from `git diff --name-only --no-renames 2cac363 9a1ca2b` (three-dot): 3 path(s). origin/main = 48c39e0, claude/issue-19498-self-tests-off-pr-path = 9a1ca2b, merge-base = 2cac363. size: +333 / -111 over 3 file(s) (0 binary, counted 0) — `git diff --numstat --no-renames` on the same range. governed-surface predicate: 0 of 3 path(s) hit the register (6 surfaces, repo-agnostic). ✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. size: 444 changed line(s) (+333 / -111) ≤ 5000 — under the human-merge threshold (generated files included in the count). VERDICT command-exit=0 ``` ## Gates `skip-changeset`: nothing under `packages/**`, nothing published. Every family `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives for this diff was run locally, each exit code captured before any pipe — 51 commands, 50 exit 0, including `check:self-test-wired`, `check:self-test-workflow-commands`, `check:step-collectors`, `check:declared-population-live`, `check:entry-guard`, `check:watch-hint-literal`, `check:required-contexts`, `check:workflow-status-functions`, `check:ci-filter-parity`, `check:bash32-floor` and `check:nul-bytes`. `pnpm check:pm-dispatch-gates` was run detached per its own header and passed: `✓ dispatch-gates self-test: 1883 cases pass.` / `the battery took 1056.1s on this box.` The one command that did not return a verdict: `pnpm check:type-check-debt` exits **3 = PREREQUISITE NOT MET** in a fresh worktree (`--re-measure cannot run: 31 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk`), which its own remedy text declares is neither a pass nor a finding. This diff touches no TypeScript, and CI builds the closure before that step. --- _Generated by [Claude Code](https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE)_ Co-authored-by: Claude <noreply@anthropic.com>
… questions only; C5 and the patrol anchor stop blocking; at-tier review scoped to what ships and read from CI (objectstack-ai#19496) (objectstack-ai#19513) Fixes objectstack-ai#19496 Clause-②: no **Tier H — the maintainer merges this by hand.** The diff touches `AGENTS.md` and `.claude/**`, so one Tier H path makes the whole PR Tier H: no seat flips it ready, queues it, or arms auto-merge, and no agent account approves it. Authorization, verbatim and untranslated — maintainer, 2026-09-21 (ruling objectstack-ai#208 on objectstack-ai#19491): > 「19491 接受你的建议,并立刻派发处理相关任务。」 ## What lands — five charter edits, text only Nothing under `scripts/`, `.github/` or `packages/` is touched. The C5 code demotion, the patrol schedule and the CI self-test scoping are their own cards. **R1 — a non-zero `--pair` blocks landing only on rows that answer a definite question.** `SKILL.md` 〈入队与落地〉 and `references/contract-review.md` 落地前检三条: ```text - `Clause-②: yes` 认领同笔卡上挂标;开 PR 跑 `--pair N`:只确定性行红才挡请审,C5 只印读数。 - 0 = 确定性行全清;4 = 任一不成立,只确定性行红才挡落地;3 = 环境答不了 ⛔ 不作干净。 - 确定性行 = 记录在案、`Served-tier:`、双载体一致、认领形;C5 放宽 tell 只报告,归复核裁。 ``` `AGENTS.md` Prime Directive objectstack-ai#14 is where the conjunct lives — `git grep -n -- '--pair' AGENTS.md .claude` is the census (1 hit in `AGENTS.md`, 5 in `.claude`). Tier S now reads `reads 0 on its definite rows (⛔ never C5) and every check is green`; the paragraph was re-wrapped from that sentence onward and `AGENTS.md` stays at 1109 lines. **R2 — the rule line**, next to the tooling rules ruling objectstack-ai#202 B landed, in 〈分诊座位职责〉's filing classes: ```text - 只报告的仪器,报错不配 dev:猜意图的只印读数,误报席位一句推翻,⛔ 不立卡不派 dev。 ``` **R3 (charter half) — the patrol anchor stops being a precondition.** The two 〈执行座位职责〉 lines are deleted; one line replaces them, mirrored in `references/core-rules.md`: ```text SKILL.md - 半状态巡查按需跑(分诊席每日对账可调),H 行是读数不是前提;⛔ 不因锚行停派发。 core-rules.md - 半状态巡查按需跑,其 H 行是读数不是前提,⛔ 不因锚行停派发。 ``` **R5 — at-tier review: scope and shape**, in `references/contract-review.md` 〈复核归属与资格〉: ```text - 复核面 = 出货给用户或 agent 的:`content/docs/**`、`apps/docs/**`、CHANGELOG/`.changeset` 散文。 - 同含已发布 schema 与 governed 规则文本;三面皆不碰 ⇒ CI 加席位自读,⛔ 不起第二个 agent。 - 复核形状:只读 diff 与卡片,check 结论取 head 的 check-runs,⛔ 永不本地重跑派生门禁族。 ``` `Served-tier:` and the record shape are unchanged. `check-clause2-carriers --template` prints nothing that contradicts the shape: its ③ is the record's boundary-flag heading and it prescribes no local gate run, so no script edit was needed (checked; reported below as a reading, not a finding). **R6 — 〈仪器纪律〉** is a new reference, `references/instrument-discipline.md` (11 lines), with exactly one pointer line in SKILL.md 〈平台读数纪律〉, placed one line below the advisory-red rule: ```text - 仪器纪律(硬门禁面、只报告面、新增授权、工具位)见 `references/instrument-discipline.md`。 ``` The section itself: ```text - 硬门禁只答有确定答案的问题:受管登记表、队列守卫、CI 测试、复核记录在不在。 - 判意图的仪器(放宽 tell、半状态巡查)只印读数 ⛔ 不挡落地,误报由席位一句话推翻。 - 放宽 tell(C5)由 `scripts/pm/check-widening-tells.mjs` 印 file:line,归达档复核裁。 - 只报告的仪器不配 dev:⛔ 不立卡、不派 dev、不开 PR;它的在途工作只有删除。 - 新增门禁、巡查行或棘轮须在卡上引维护者原话,⛔ 无原话不新增;首行四件恒硬。 - 工具位只有一个,先花在删除上;`dispatch-gates.mjs` 冻结,只在它喂的 workflow 坏了时碰。 - 出处:维护者 2026-09-21 逐字「19491 接受你的建议,并立刻派发处理相关任务。」 ``` **Why a new file rather than `landing-operations.md`.** Every file an at-tier reader already opens for landing or gates stands at its ceiling with zero headroom — `landing-operations.md` 69/69, `true-green.md` 32/32, `review-checklist.md` 77/77 — and the section costs 11 lines, so hosting it in one of them means deleting live rules, which this card forbids. The pointer instead sits where the reader deciding what a gate reading means already is: 〈平台读数纪律〉, right after the two lines on a gate job's conclusion and an advisory red. The new file carries no `CEILINGS` row, because that row is an edit under `scripts/**` (out of scope here) and R6's own rule says a new ratchet needs the maintainer's sentence on its own card — reported below. ## The line ratchet — no ceiling raised, every added line paid in place `pnpm check:pm-skill-ratchet` exit 0. Per-file counts on head `fec2177089`, after merging `origin/main`: | file | lines | ceiling | headroom | net this PR | |:--|--:|--:|--:|--:| | `.claude/skills/pm-dispatch/SKILL.md` | 819 | 819 | 0 | 0 | | `.claude/skills/pm-dispatch/references/contract-review.md` | 60 | 60 | 0 | 0 | | `.claude/skills/pm-dispatch/references/core-rules.md` | 151 | 151 | 0 | 0 | | `AGENTS.md` | 1109 | 1109 | 0 | 0 | | `.claude/skills/pm-dispatch/references/instrument-discipline.md` | 11 | none | — | new file | What paid for the added lines — de-duplication only, no rule deleted, each surviving carrier named: - SKILL.md 〈复核〉's two 受管面两层 lines became one. The Tier H enumeration it dropped is the 〈复核〉 line three above it (governed 面统一定义), and 四件套 is stated at 路径面命中规则层 ⇒ ACCEPT 换终局四件套. - contract-review.md's 双载体同笔挂 line is stated in SKILL.md 〈入队与落地〉 (`needs:contract-review`(恒英文)由席位同笔挂:PR 一现即挂 PR;报告先到则先挂卡); its unique tail, ACCEPT 补齐 PR 侧, rides the next line. - contract-review.md's two `Implemented-by:` / `Reviewed-by:` spelling lines became one pointer at `--template`, which prints both fields verbatim — and the record-shape line three above already cites that template. - contract-review.md's standalone Tier H/S landing line left; the tier outcome now rides the 落地前检三条 line itself, so `references/lanes/director.md`'s pointer at this block still resolves, and SKILL.md 〈复核〉 carries the full two-tier rule. - `AGENTS.md`: 43 added bytes absorbed into the paragraph's own slack by re-wrapping from the edited sentence onward; no line was bought. ## Collision — both charter PRs merge clean - **PR objectstack-ai#19462** (`claude/issue-19457-charter-product-only-queue`, head `36ab00aa`) **merged into `main` during this round.** `git merge-tree --write-tree 36ab00a HEAD` was exit 0 with zero conflict markers before that, and this branch then merged `origin/main` (`48c39e00`) with no conflict. Its `tooling` label rules, its two gate-false-positive lines and its ratchet bump (SKILL.md 813 → 819) are all present on this head. - **PR objectstack-ai#19488** (`claude/issue-19483-feature-axis-charter`): the card named head `420bd091`; the branch tip is now `16418377`. `git merge-tree --write-tree 1641837 fec2177` → **exit 0**, tree `02a7323a2d779974bd035082ad954eaf4cd15a21`, zero conflict markers. Every line that PR touches is untouched here. ## Acceptance | grep | result | |:--|:--| | `git grep -n '0 才请审' .claude` | 0 hits (exit 1) | | control `git grep -n -- '--pair' .claude` | 5 hits (os-dev.md 1, SKILL.md 1, contract-review.md 2, platform-readings.md 1) | | `git grep -n '锚行未处置' .claude` | 0 hits (exit 1) | | control `git grep -n '半状态' .claude` | 24 hits across 8 files | | `git grep -n 'C5' …/references/contract-review.md` | 1 hit, naming it 只报告 | ## Gates Derived on this diff with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` — 23 commands; the derivation also names 11 wide-population, 53 artifact-roster, 14 pending-changeset and 2 CI-valued families as outside that list, so this is not a complete account of CI. Every exit code was captured **before** any pipe (`cmd > log 2>&1; e=$?`). Reconciled with `--ran`: **23 derived, 23 run, 0 NOT-MEASURED, 0 UNRUN.** ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/pm/check-governed-queue-guard.mjs --self-test :: exit 0 node scripts/pm/check-harness-current.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-expected-skips :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-governed-prose :: exit 0 pnpm check:pm-half-states :: exit 0 pnpm check:pm-skill-id-lint :: exit 0 pnpm check:pm-skill-ratchet :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:required-contexts :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` Three more, run because this card names them: ```text pnpm check:pm-widening-tells :: exit 0 (self-test only — 525 cases; checker health, not a verdict on this diff) node scripts/pm/check-widening-tells.mjs --declaration no --diff pr.diff :: exit 0 (a real verdict: no tell) pnpm check:pm-label-desc-cap :: exit 0 pnpm check:pm-settings-deny-roster :: exit 0 (its roster lives under .claude, which this diff is in) ``` Two gates first answered `exit 3` (PREREQUISITE NOT MET) in a fresh worktree and were re-run after `pnpm install`, and `check:doc-formula-expressions` after `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` under the shared verify lock (VERDICT command-exit 0). Those 3s are recorded as what they are — nothing measured, not a finding. `skip-changeset` applies: the diff is `.claude/**` plus `AGENTS.md`, nothing under `packages/**`, and no published `files[]` content moves. ## Acceptance notes — out of scope, not filed by the dev - SKILL.md 〈状态模型〉 still says 派发与折叠检查时读半状态巡查锚的 H17 触发文件索引. With the patrol's schedule retired on its own card, that index can go stale; this card's R3 scope was the two 〈执行座位职责〉 lines only. Dedupe words: 半状态巡查锚, H17, 触发文件索引, 折叠检查. - `references/instrument-discipline.md` carries no `CEILINGS` row, so it is the one un-ratcheted file on the pm-dispatch surface. Adding the row is an edit under `scripts/**`, and by R6's own rule a new ratchet needs the maintainer's sentence on its card. Dedupe words: CEILINGS, instrument-discipline, ratchet row, un-ratcheted reference. - `check-clause2-carriers --template` prints no line contradicting R5's shape (checked; no script edit). ## 维护者速读(草稿) **改了什么** — 把「仪器」这件事写成纪律:`--pair` 这类硬门禁只在能给出确定答案的行上挡落地(复核记录在不在、档位行在不在、两个标签载体一不一致、认领形对不对);猜意图的那一行(C5 放宽 tell)从此只印读数,由达档复核的人判。半状态巡查从「每轮派发的前置条件」降为「按需跑的读数」。达档复核的范围收到「会出货给用户或 agent 的东西」,形状收到「读 diff 加读 CI 的 check-runs」,不再本地重跑门禁。新增一页〈仪器纪律〉,把这几条连同「只报告的仪器不配 dev」「新增门禁要维护者一句话」「工具位先花在删除上」写在一起。 **为什么改** — 裁决 objectstack-ai#208 的实测:工具链自己占了三到四成的合并量、每个 PR 三分之二的 CI 关键路径;把 objectstack-ai#19314 挡住的那道门,本体只是两句章程话,不是 CI 门禁。一次达档复核 24 万 token、29 分钟,其中最大一块是在本地重跑 CI 已经跑过的 37 个门禁族。这三件都不是删代码能解决的,是纪律写错了地方。 **风险与代价(含回滚)** — 代价是硬门变软:C5 不再挡人,漏网要靠复核的人看见。回滚是一次 revert,因为全是文本。棘轮一行没抬,新增的行全部用去重付账,幸存载体逐条点名在上面;唯一的新面是那一页新文件,它暂时没有棘轮行。 **席位意见** — **你要做的** — 读这五处改动,同意就人工合并(Tier H,队列与 auto-merge 都不适用)。 ## Round 2 (seat's note) Head `96774e44ef`. The at-tier review (5755678024, FAIL) named three items; all fixed in one push: R5's obligation now keys on the **face**, not the lane (`references/contract-review.md` 〈复核归属与资格(按面)〉 lines 24–27, SKILL.md line 647, plus the two lane-keyed twins at SKILL.md 533 and core-rules.md 113 — 「三面」 is now 「五面」 and the published schema names its path `packages/spec/src/**` non-test); `origin/main` (`ea64bbc6e8`) merged with the SKILL.md 180/181 conflict resolved keep-both; `instrument-discipline.md` cites ruling 208 / card 19491 / comment 5755284987 with bare ids (the `#` spelling is what `check:pm-skill-id-lint` refuses). Ratchet: SKILL.md 815/819, contract-review.md 60/60, core-rules.md 151/151, AGENTS.md 1109/1109. CI: this head has zero GitHub-Actions runs — a `.claude/**` plus `AGENTS.md` diff matches no PR-level workflow paths; the merge queue's `merge_group` run supplies the required contexts, so the empty check list is the known shape and not a stall. --- _Generated by [Claude Code](https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373) Fixes objectstack-ai#17518 Clause-②: yes Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194, maintainer 「192 同意」. Its two steps, its refusals (A and B) and its fences are followed as written; every place where the tree made me read the ruling rather than transcribe it is called out below. Base of every reading in this body: regeneration commit `96dd3549ff6`, the head of the SIXTH merge. >⚠️ **The readings below were brought to this head by the seat, not by the round that first wrote them.** Two merge rounds have run since the first draft. Each figure corrected here is named in the correcting round's own report on card objectstack-ai#17518 — comment 5750725852 for the first, 5750987577 for the second — and the seat re-verified the head, the regenerated index and mergeability itself before editing. Anything not listed in those two reports is the original round's reading, unchanged. ## The confidence gap the ruling asked me to close first 「whether `effect` is required or defaulted on the declaration schema — read it, ⛔ do not mint a value」 **Defaulted.** `FlowFunctionDeclarationSchema.effect` is `FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured, not read off the source alone: `FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })` succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member of `functions` states `FlowFunctionEffectSchema.optional()` with **no** default, so the two forms differ and neither is restated anywhere in this diff — each JSON stage inherits its form's own optionality by deriving from it. That reading is what the producer writes: the bare-callable normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form gets nothing. ## What landed **`packages/spec/src/automation/flow-function.zod.ts`** — `FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its `FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a module-local `const`, and `automation/index.ts`'s `export *` only re-exports what is already exported. **`packages/spec/src/stack.zod.ts`** — two new bodies **beside** `AssembledPackageBodySchema`: - `ArtifactStagePackageBodySchema` — the on-disk artifact stage. `functions` entries are the lowered spellings, `hooks[].handler` is a string. - `RecordStagePackageBodySchema` — the registry record stage: literally `ArtifactStagePackageBodySchema.extend({ functions: … })` with `functions[].handler` optional in both the map-record form and the array form, and nothing else. `AssembledPackageBodySchema`, `composeStacks` and the `cannot drift` invariant are ⛔ untouched: those callables are live on the stage the assembled body declares itself for, and narrowing it would refuse a published composition function's own output. Both new schemas carry the same structural `z.ZodType` annotation as the assembled body, for the two reasons recorded there (TS7056; a named alias turning `stack.zod` into a shared chunk). **`packages/spec/src/api/package-api.zod.ts`** — the installed-package row's `manifest` is rebound to the record stage (step 1). The `z.unknown()` override and the docblock defending it are gone, and the sentence that ruling A step 5 assigns to this edit is corrected in place: those two members are **not** why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish no JSON Schema — `src/stack.zod.ts` is not one of the subpath namespaces `build-schemas.ts` walks, so neither is ever reached by the emit loop. **`packages/objectql/src/registry.ts`** — step 2. `withDeclaredFunctionEntries` rewrites a bare callable `functions` map entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s structural rule is ⛔ untouched and no key is special-cased inside the projection; the two spellings are simply made structurally equal ahead of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest is never mutated and a copy is made only when an entry really needed rewriting. ## Two places where I read the ruling rather than transcribed it — both stated so they can be overruled 1. **「`functions` entries the lowered declaration」 is implemented as BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and `{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage admitting only the record form would refuse artifacts this repo really writes — the failure mode that withdrew letter B, one key across. Ruling A′'s own step-4 control names both shapes (「a string and a lowered record」). Measured: the artifact stage accepts a body carrying one of each. 2. **The array member is transcribed, not derived.** `functions`' array branch is declared inline inside the assembled body's own shape, and narrowing it in place is the one thing this pair may not do. The transcription's drift is guarded instead: `stack-json-stage-package-body.test.ts` pins the authoring array entry's key set equal to both JSON stages', so a key added there and not here reddens by name. ## Acceptance, as ruling A′ lists it | criterion | result | |---|---| | both bodies convert under `z.toJSONSchema` (self-test over the whole body) | **YES** / **YES**; control: the assembled body still **NO** (`Function types cannot be represented in JSON Schema`); probe controls lit `z.string()` YES, dark `z.object({a: z.function()})` NO | | the showcase-shaped manifest (`config.ts:244-249`) reports **2** functions on the `GET /packages` row, the bare one as a handler-less declaration | **2**: `{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`, driven through the real `SchemaRegistry.installPackage` | | `hooks` unchanged | unchanged: an inline handler is dropped (the key is optional and admits that), a string handler survives verbatim. The array `functions` form also keeps its entry: `[{"name":"syncBilling","effect":"writes"}]` | | `AssembledPackageBodySchema` / `composeStacks` / the invariant untouched | untouched — no edit in those regions; `assembled-package-body.test.ts` and `compose-stacks-manifest-preserve.test.ts` stay green | | the two `noted, not filed` corrections in the same edit | baseline reason line: made TRUE by step 1 rather than reworded — `automation/FlowFunctionLoweredDeclaration` is now in `json-schema.manifest/automation.json`, so 「the lowered record … publishes normally」 is now a fact. `package-api.zod.ts` docblock last sentence: corrected in place, see above | Stage separation, measured rather than asserted: the record stage accepts the handler-less declaration and the **artifact** stage refuses it; the assembled body accepts a live callable and **both** JSON stages refuse it; both JSON stages still refuse an authoring glob and an unknown key (`namesapce`). So the two keys moved from `unknown` to a declaration, and nothing else moved. ## Reverse verification — two ablations, each restored with proof Both ran against committed code, each with a `trap` restore, an on-disk landing proof (anchor `grep -c` before/after plus a blob-hash change) and a restore proof (`git hash-object` back to the HEAD blob, `git diff HEAD` empty). - **A1 — remove the producer normalisation** (`toRecordManifest(withDeclaredFunctionEntries(manifest))` → `toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` → `17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1 failed / 15 passed**, naming the exact defect — `expected [ 'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1) ]`. Restored blob `b0af60d7…`, diff empty. - **A3 — collapse the record stage into the artifact stage** (`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob `60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files — `record accepts the handler-less declaration; ⛔ the ARTIFACT stage refuses it` and `parses a row carrying the residual the projection really produces`. So the one-key difference that IS the fourth stage is load-bearing in both packages' pins. Restored blob `60c13b43…`, diff empty. No ablation is offered for 「both bodies convert」: that claim already carries its discriminating control inside the same test file (the assembled body must NOT convert), which is a lit/dark pair rather than an assertion about itself. ## Tests and gates All through `scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own `VERDICT command-exit` line and never a bare `$?`; every exit code captured before any pipe. Wall-clock figures in the logs are SHARED-BOX seconds. - `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests passed, 1 todo** — the FULL suite, re-run on this head because the sixth merge carried 128 commits of base movement including breaking spec changes - `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests passed** - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2` over the package-door / artifact population, enumerated by a name match on `packages/runtime` for `package` or `artifact` so the population is reproducible — **39 files / 512 tests passed**.⚠️ The first attempt exited 1 in 2 seconds and is recorded as NOT a red: the paths were repo-root-relative while `pnpm exec` runs at the package root, and the repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the 39 path(s) you named will run no tests`). Re-run with package-relative paths for the reading above. - `pnpm --filter @objectstack/spec --filter @objectstack/objectql typecheck` — exit 0; both test layers compile (spec **53 files / 257 errors / 142 pins**; objectql **40 / 234 / 65**, unchanged).⚠️ The spec ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔ not by this PR. - `pnpm --filter @objectstack/spec --filter @objectstack/objectql typecheck` — both exit 0 on this head; the debt ledgers held shrink-only (spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 / 65). - `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts` present, `check-dts-references` resolved 378/378), and the whole `@objectstack/runtime` dependency closure was rebuilt first, so nothing below read a dist stale against 128 commits of main. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived from this tree, every command run with its exit code written to a file, reconciled with `--ran`: **116 derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict line says so. **113 exit 0.** The two NOT-MEASURED are the tool's DERIVED classification of an exit 3; a third measured nothing too, and the tool cannot see it because its refusal code is 2. ⛔ None of the three is a finding: - `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no `dist`; it wants a whole-repo build). - `check:type-check-debt` — exit **3**, same shape, same wording, wants the full package closure built. - `check-engine-split-ratio --days 90` — exit **2**, refuses on a shallow clone whose oldest visible commit sits inside the 90-day window. It says a ratio derived there would be 「real, plausible and WRONG」. A fourth, `check:skill-examples`, first exited 1 on an unbuilt `packages/client-react`; after building that package it re-runs **green** — 258 prose examples type-check across 3 surfaces. Both readings are stated here, and the reconciliation record carries ONE of them — the green re-run — because the tool flags a doubly-recorded family and says to make the record state one thing. The re-derivation on the final head yields **116** families: `check:api-surface-declarations` is gone (retired upstream by objectstack-ai#19024 mid-round) and `check:gitlink-declared` is new, run green. No family is left unrun. Ratchet families re-run after the last merge, on `96dd3549ff6`: `check:generated` (all 15 artifacts up to date), `check:api-surface`, `check:authorable-surface`, `check:export-origins`, `check:declaration-map`, `check:docs`, `check:skill-refs`, `check:entry-nameability`, `check:dual-source-exports`, `check:spec-changes`, `check:spec-parsed-alias`, `check:published-files`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage` — all exit 0. Control characters: `grep -naP` over every file I hand-edited returns nothing (exit 1). ## Generated artefacts in this diff, and why each moved - `json-schema.manifest/automation.json`, `authorable-surface/automation.json`, `authorable-defaults/automation.json`, `api-surface/*`, `export-origins/*`, `declaration-map/automation.json`, `content/docs/references/**` — the new exports, regenerated by the package's own `gen:` scripts. `authorable-defaults` records `automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is the confidence-gap reading in ledger form. - `packages/spec/dropped-refinements.baseline.json` — four `api/*` entries each gain one site (`…manifest.hooks.element.object`), counts 569 → 573. Cause: the record stage **declares** `hooks` where `z.unknown()` declared nothing, so `HookSchema`'s `object` refinement now reaches the runtime and not the published file. The ledger is hand-edited by design and the build printed the exact delta. - `skills/objectstack-platform/references/_index.md` — one generated line listing `stack.zod.ts`'s exports. ## `skills/**` readings, and the landing tier This diff touches `skills/objectstack-platform/references/_index.md`, so the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and no AI seat merges, queues or arms auto-merge on it. Both readings the skills rule requires, at merge base `c334ba0f3a6`: - **changed file, whole file**: 41 lines before, 41 after — net **0**. The diff is one regenerated line. - **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after — net **0**. `node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies this file as **generator-owned (measured, not ratcheted)**, so no authored ceiling is charged. ## Clause ②, and the changeset is not one package's `Clause-②: yes`, and two changesets because two published packages move: - `@objectstack/spec` — **minor**. New exports, and the two installed-package responses move from `z.unknown()` on `functions` / `hooks` to declared JSON shapes. That is a narrowing on a published declaration; what it does NOT withdraw is measured, on real producers: the showcase shape, the array form and the already-lowered body an artifact boot installs all parse. - `@objectstack/objectql` — **patch**. `GET /packages` reports functions it previously dropped. No API is added or removed; a read door stops under-reporting. Grade it up if a payload gaining entries reads as minor to the reviewer. ## Serial and merge state, re-taken by this seat Changed-file map re-taken first-hand over all **33** open PRs (271 file rows) rather than inherited. LIT control `packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK control `packages/spec/src/zzz-no-such.zod.ts` resolves 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 objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly here (its `stack.zod.ts` hunk is a comment). - `packages/spec/dropped-refinements.baseline.json` — also written by objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which rewrites the same `measured` header and adds entries. That is a line-level contention on a ledger whose correct value is recomputable: whoever lands second re-runs `pnpm --filter @objectstack/spec build` and re-applies the delta it prints. ⛔ Not a semantic collision. `origin/main` has been merged **six** times on this branch. `objectstack-ai#19024` (which retired `api-surface-declarations/`) came in early, which is why no `api-surface-declarations/*.txt` appears in this diff. The fifth merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge, the head of this body, brought **128 commits** — so the full spec suite was re-run rather than only the generated gates. ⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its `rerun` arm is re-entrant and commits a revert of the operator's own regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order were performed by hand, against a merge base captured BEFORE the merge and an `origin/main` fetched into an OWNED ref so a sibling's fetch could not move the target mid-round. **The sixth merge decided THREE paths, and only one of them was a conflict.** That gap is worth stating, because resolving only what a conflict probe names would have landed a silent loss: | path | routed | what the merge did | how it was resolved | |:--|:--|:--|:--| | `content/docs/references/index.mdx` | `merge=os-regen` | driver deferred it, exit 0 — **main's side silently dropped** (merged blob `6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored into the WORKING TREE ONLY, then regenerated whole | | `content/docs/references/api/package-api.mdx` | `merge=os-regen` | same — **main's side silently dropped** (merged `988bedaa480` == ours, != theirs `d09cd420711`) | same | | `packages/spec/dropped-refinements.baseline.json` | **not** routed | exit 1 — the only real text conflict, one hunk, confined to three summary counters in the `measured` header | both sides' entries unioned, then the build adjudicated |⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It text-merges cleanly driver-free, so a GitHub-condition probe cannot name it; only the both-edited ROUTED set, computed per file against the pre-merge base, finds it — which is exactly what `os-regen-merge.sh` step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending` record listed. **The regenerated docs are the UNION, proven in both directions** (added/removed line multisets compared as sets): `package-api.mdx` identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines, excluding the two running-total lines — a union MUST move a total neither side moves alone, so their disagreement is the signature of a correct union rather than a failure, and the line counts already matched (16/16, 10/10) before excluding them. The total is **re-derived, not arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged tree **1535**, and 1535 is what `gen:schema` itself reports for the merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and `DatasetTotals` and retired `KernelSecurityScanResult` / `KernelSecurityVulnerability`; this branch brought `FlowFunctionLoweredDeclaration`. All survive, asserted through the published export map of the freshly built dist with a dark control (an invented export name reads undefined). **The ledger was resolved by hand, and that is the only route available.** `dropped-refinements.baseline.json` is hand-edited BY DESIGN with no `gen:` script — its own description states why: *"a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end."* The build VALIDATES it bidirectionally and refuses; it never writes it. Both sides' entries were unioned (union keys missing from the merged file: **none**; merged keys not in the union: **none**; `api/DatasetSelection` arrived from main via objectstack-ai#19638 and survives; main's removal of the `fields.out.keyType` sites is kept — **nine** site lines at the merge base, zero at this head and zero on main (lit control: 204 `"sites"` keys at base; dark control 0).⚠️ The merge round's own prose said *five*; that was a narrative miscount caught by the merge-delta review and re-counted by the seat. The FILE was always right), then `gen:schema` adjudicated and measured 565 dropped sites across 205 published schemas — the union as resolved. One counter the build corrected: `refinementSitesThatDidProject` read 357 and the build measures 366.⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the repository would have caught it: two of the four `measured` counters have no reader anywhere (lit control — the other two have two readers each, dark control 0), so they can hold any number and every gate stays green. ## Acceptance notes - **noted, not filed**: regenerating `packages/spec/api-surface-declarations/ui.txt` produced a 184-line change that is a pure permutation of its own content — the same union members in a different order, `0 removed, 0 added, 35 reshaped`. Verified as a precedented shape rather than a defect: commit `24d622b94b8`, a spec change touching **zero** files under `packages/spec/src/ui/`, moved the same file by 5 lines whose sorted content is byte-identical. The whole artefact was retired upstream by objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the population is gone. **Carrier: none — the file no longer exists.** - **noted, not filed**: `packages/objectql`'s tests resolve `@objectstack/metadata-protocol` from `dist`, so after merging upstream objectstack-ai#19277 the seven assertions in `protocol-install-package-enable-on-install.test.ts` failed against a stale build of a package this PR never touches; building that one package turns all seven green. A local-environment reading, not a repo defect, and `check:test-source-alias` already owns the aliased/unaliased ledger this sits in. **Carrier: the next seat that runs objectql's suite after a merge — it will see the same red and should build the dependency before reading it as a finding.** ## 维护者速读(草稿) **改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact 的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘 artifact)和 `RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET /packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。 **为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而 JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API 只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1 个——机器可读的读门把事实说少了。 **风险与代价(含回滚)** —— 风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase 的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与 `composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B 被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert` 任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。 **席位意见** —— **你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:① `@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算 minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1 条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。 --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19150
Clause-②: no
declaresCollection(packages/spec/src/stack.zod.ts) read onlydef.inon itspipearm, so az.preprocess-wrapped collection key resolved to atransformnode, fell through todefault: return false, and silently left the key setobjectConflict: 'merge'refuses to combine (#14848).⭐ No current behaviour is wrong and none changes here.
objectCollectionKeys()skipsfieldsby name, and measured over all 43 top-level keys ofObjectSchemathe derived refusal set is identical before and after. This is a finding fixed before it can bite, not a regression report.1. The census — what the card asked for FIRST
The card records this as NOT measured: "whether any OTHER
packages/specwalker carries the samepipearm … there were two copies of this arm and only one is fixed, which is a rate, not an anecdote."Scanned 6890 tracked TS/JS files (
node_modules/,dist/excluded) onorigin/mainate6a03e6491for every site that DISPATCHES on a zodpipenode —case 'pipe',type === 'pipe',instanceof z.ZodPipe. 13 sites, each classified by hand from its arm:spec/src/stack.zod.ts:3415·spec/src/compose-stacks-merge-collection-refusal.test.ts:222·lint/src/component-field-specs-liveness.test.ts:68·spec/src/ui/component.test.ts:2907spec/scripts/lib/zod-graph.ts:232(pipeAuthorableSide, the canonical one) ·spec/scripts/liveness/check-liveness.mts:592·spec/scripts/liveness/tombstoned-row-status.test.ts:101·spec/src/kernel/metadata-authoring-lint.ts:134·spec/src/system/metadata-form-zod-reconciliation.test.ts:172spec/src/kernel/metadata-type-schemas.test.ts:128(union of both) ·:558(OUT first, then IN)spec/scripts/zod-graph.test.ts:182(the pin ONpipeAuthorableSide) ·lint/src/validate-predicate-path-refs.ts:369counted above as transform-discriminatedBoth known targets fire, which is the ruler check the card asked for:
stack.zod.ts(this card) and the test-side copy.Three corrections the census produces:
main.compose-stacks-merge-collection-refusal.test.ts:222still readsisCollection(def!.in, …)ate6a03e6491. The card's "already fixed one file over" describes PR spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147's BRANCH, which is still open and draft. ⛔ Untouched here on purpose — that file is spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147's surface.component-field-specs-liveness.test.tsrecords"TYPE: props schema has no resolvable object shape"(the type name, then that sentence) as a violation when the walk reaches no shape;component.test.ts:2907reads.shape.propertiesoff the result and would throw. Neither can go quietly green on a preprocess-wrapped input. They are noted below, not filed.2. The fix shape — measured, then chosen
The card deliberately left three candidates open. The landed rule reads OUT only when IN unwraps to a transform stage:
in || out(the shape spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 applied test-side): for a genuinea.transform(fn).pipe(b)the author writesa.z.string().transform((s) => s.split(',')).pipe(z.array(z.string()))is a key whose AUTHORED value is a scalar and whose parsed value is an array;in || outputs it in a refusal set that then tells the author their scalar is a collection whose entries would be dropped. Pinned as a dark-control assertion, not argued in prose:eitherSideWalkanswerstruefor that shape, the landed rule answersfalse, andcomposeStackscomposes it by later-wins.composeStacksat author time; the derivation's job is to answer a structural question about every key, and a throw on a shape that is legal today would convert a silent gap into an outage.pipeAuthorableSideinscripts/lib/zod-graph.tssincebuild-schemas.ts的zodShapeOf对z.preprocess走错管道方向(#4488 已在 check-liveness 修过的同一个盲点) #5317,metadata-authoring-lint.tsandmetadata-form-zod-reconciliation.test.tssinceViewItemSchema同时是授权形状和 Studio 往返的 wire 成员 —— 拆成两个 schema 还是保持宽松?(挡住 #4001 批 18 最后 2 站点) #5074,packages/lint'svalidate-predicate-path-refs.ts), each carrying the 活性账本覆盖 worklist:9 个已注册 metadata type 仍未治理(#4487 建立闸门后的剩余债务) #4488 citation. Adopting it makes this a fifth SITE of one rule rather than a fifth dialect. The unwrap before the transform test is load-bearing and is pinned: a transform one level down is still a transform.3. The measurement, per key
ObjectSchema.shape— 43 top-level keys, read off the built package:titleFormat,optional > union[ pipe(in=string, out=transform) | object ], ana.transform(fn)pipe carrying a scalar.in || out: 0 of 43.indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions(9 keys).fieldsis a plainrecordonmaintoday and is excluded by NAME either way, so its own reading cannot move the set. After spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 wraps it inz.preprocessits reading changes (IN-onlyfalse, authorable-sidetrue) and the set is still unmoved, because the exclusion is by name.That invariant is an ASSERTION, not a claim in this body:
compose-stacks-collection-pipe-arm.test.ts's last block derives the set under all three readings from the unmocked shape and fails the day they stop agreeing — which is the day this fix starts doing observable work.4. Tests — bright / main / dark, driven through the real production walk
declaresCollectionis internal and today's shape has no preprocess-wrapped collection key, so a pin written against the shape alone cannot tell a fixed walker from an unfixed one. The new file mounts three probe keys onObjectSchema.shapethroughvi.mock— the only inputobjectCollectionKeys()reads — and drives them throughcomposeStacksitself:pipewithin=transform, out=array; and apipewhose IN is itself the.transform()pipe).prefaultwrapper.composeStacksrefuses two differing declarations of that key, and the refusal message ENUMERATES the derived set, so the set change is read per key: the probe key joins, and the nine keys that were there before are still there, in order. Identical declarations still compose..pipe()probe and a plain scalar both compose by later-wins, unchanged;actionsis still refused exactly as before; andin || outis pinned as the reading that WOULD have moved the.pipe()probe.Ablation (one-shot, on the committed state,
scripts/ablation-replace.mjs): the arm reverted todeclaresCollection(def.in, depth + 1), mutation proven on disk (anchor1 -> 0, blobbdb4aa8c12bc -> 82b7d2ba3774,grep -cof the injected text1and of the removed text0) — 2 tests fail, both of them the MAIN leg, with the other 12 green, which is the expected direction: the bright and dark legs do not depend on the fix. Restored by the same tool, verifiedblob == HEAD (bdb4aa8c12bc)andgit diff HEADempty.dist/is not on the resolution path here — the subject is reached by a same-package relative import from the test — so the rebuild-to-dist preflight does not apply and no dist marker was involved.Runs (all on
8c50307884, this PR's head; shared box, so seconds are contention figures):pnpm --filter @objectstack/spec test— 501 files / 14657 tests passed, exit 0.pnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit+ scripts + test layer).pnpm --filter @objectstack/spec check:generated— all 16 generated artifacts up to date; nothing to regenerate.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0, no narrowing claimed.scripts/pm/dispatch-gates.mjs --ran— 80 derived families accounted for: 77 run green, 3 NOT MEASURED (check:type-check-debt,check:lean-entry-closure,check:dual-build-cjs-loads— each exits 3 PREREQUISITE NOT MET without a full workspace build, which CI does first; none is a finding).@objectstack/specdeclares no workspace dependency, sopnpm --filter '@objectstack/spec^...' buildmatches no project.5. Clause-② — the push-back the dispatch asked for
Clause-②: yesstill stood and was left in place when the 10:59Z ruling above re-declaredno. Both of its claims are false at this head, measured rather than inferred: line 3 of this body readsClause-②: no, and.changeset/19150-declares-collection-pipe-authorable-side.mdgrades'@objectstack/spec': patch, notminor. What survives from it is the path limb alone —SUSPECT_TIER_GLOBS=packages/spec/src/**makes this a contract-surface PR regardless of any declaration, which is why the lane owes the at-tier contract review that is now on record (comment5750417684,Head-sha: 7d67e1ee41…, VERDICT: PASS,Clause-②: noupheld by independent re-derivation). Kept rather than deleted, because a body that quietly loses what it once claimed is worse than one that carries its own correction:⭐ The reading the dispatch asked for, and it points the other way: published behaviour does not move by one row. 0 of 43 key verdicts change, the refusal set is identical, no export is added or removed (
check:api-surfacegreen), and no authored metadata changes meaning. By the gate's own words for clause ② — "this PR puts a new key on a published payload" — nothing here does. If the seat accepts that reading, the downgrade is three coordinated edits (the card's claim line, this body's line, and the changeset level) and is the PM's to make, not a dev's unilateral carrier split.Acceptance notes
Out of scope, noted and NOT filed — neither is a reproducible defect, a declared-contract violation or a metadata-authoring trap:
packages/lint/src/component-field-specs-liveness.test.ts:68—shapeOfreadsdef.inonly. A preprocess-wrappedComponentPropsMapschema would make it record"props schema has no resolvable object shape"— a LOUD red, not a silent pass. Carrier: none today; no such schema exists.packages/spec/src/ui/component.test.ts:2907—def.in._zod.def.shapeonPageComponentSchema(.strict().transform(…)). Same shape, same loud failure (a TypeError on the next line). Carrier: none today.in || outwhile the production walk reads the authorable side. Measured on today's shape the two agree, and the invariance block above asserts it — but they are two rules answering one question, which is the drift the derivation exists to avoid. The one-line alignment belongs to whoever lands spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147, since that file is its surface today.Authored by Claude Code in session
session_01AmH9bKvGoLjiY86Q4Z3og2; attribution is repeated in prose because the platform rewrites the footer block on some write channels.Generated by Claude Code