feat(spec)!: every engine-evaluated expression slot requires a non-blank source - #18638
Conversation
…ed slots Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…y envelope Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…ing arm Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…ns and reference docs Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 546adee9fd0285407ad26a2a2a30f86c01a75f5e && git checkout 546adee9fd0285407ad26a2a2a30f86c01a75f5e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4cb05cbf0a8dda962974533ee634393a66440fa 34a63b9d583c881d4c45d7187206417b0c82c17b && git checkout -B drift-repro d4cb05cbf0a8dda962974533ee634393a66440fa && git merge --no-ff 34a63b9d583c881d4c45d7187206417b0c82c17b
node scripts/docs-audit/affected-docs.mjs --json d4cb05cbf0a8dda962974533ee634393a66440fa
|
A narrowing PR that grows the published export surface widens on a second axis. `shared/evaluated-slot-union.ts` follows the `union-branch-policy` convention: reachable inside `@objectstack/spec`, absent from both barrels, so `api-surface/` and `export-origins/` do not move for it. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Seat verdict: REWORK — the review PASSed and two of its named corrections ship false textSeat What the review settled, and it settled it well⭐ The central question — whether It also gives #18640 its root cause: the instrument's #17618 three-fact spend requires the added member list to be a textual subset of the removed one, and here the member was renamed. ⛔ The gate's prescribed clear is a matcher repair with a self-test case, ⛔ never a false What must change — inside this PR's own surface
⛔ Not owed — do not re-open
Carried, not foldedThe review named two things that are ⛔ not this PR's: Generated by Claude Code |
…s messages
The changeset and the ADR-0087 migration entry both said the union-member
positions leave their sibling arm untouched. Measured on this branch's base
`00115a8442` and at head: true for `RecordAlertProps.visible` (boolean) and
`ServiceLevelIndicator.successCriteria` (structured object), FALSE for
`TraceSamplingConfig.composite[].condition`, whose record arm gained a
`.refine()` and refuses six shapes the base accepted through that arm alone.
A changeset becomes the CHANGELOG and a migration entry becomes the migration
ledger, so both now state the narrowing, its FROM -> TO and its control.
The refine becomes aborting. That is about the MESSAGE and never the accept
set (measured identical either way): non-aborting, it was the surviving arm
for every expression-shaped refusal here, so a blank `source` collided with it
and the slot published a bare `Invalid input` while the sentence sat nested,
and `{ dialect: 'js', source: 'x' }` was refused with a sentence about
`source` that misnames its fault. Aborting hands each refusal back to its
owner: one `custom` issue at `source` for a blank `source`, one
`invalid_union` carrying the published sentence for an `ast`-only envelope or
a blank bare string.
Pins: the tracing slot's accept set, both blank spellings' published sentence,
and the negative (a non-`source` fault is not blamed on `source`); and the
population pin now covers all three refused spellings at all 36 positions
instead of only the `ast`-only one.
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…nce page `gen:migration-registry` picks up the corrected surface / acceptance text, and `gen:docs` republishes `TraceSamplingConfig.composite[].condition` with the `dialect` rule its schema enforces. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…aluated-slot-narrowing
…ribe `check:doc-authoring` flags an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12). The rule the describe publishes is unchanged; only the trailing reference is gone, and the reference page is regenerated. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS
Generated by Claude Code |
契约复核记录 — 交付后复核(delta 轮),档位
|
⛔ 落地阻断 —— 契约复核 PASS,但本 PR 在 CI 上真红 16 条,且红是本 PR 自己造成的
两件事互不矛盾,先说清楚契约复核的 PASS 成立且在其职权内:它测的是契约语义(接受集、semver 档、公开面),它在自己的隔离检出里只构建了 入队资格是本席的职权:「入队资格 = 每个 check 为 success 或预期 skip,⛔ 不是 required 子集」。本席读了,不合格。 实测(head
|
| 结论 | 条数 |
|---|---|
| 在 main 上也红 | 1 —— TypeScript Type Check |
| 在 main 上是 success,只在本 PR 红 | 16 |
⭐ 仪器是亮的:对照表两个分支都取到了值 —— TypeScript Type Check 命中「main 上也红」,证明该分支可达;⛔ 不是一个永远只会答「你的锅」的坏仪器。
在 main 上绿、只在本 PR 红的 16 条:
Build Core · Test Core + 6 个分片 · Dogfood Regression Gate + 3 个分片 · Dogfood Verify CLI · Type Check · workspace · Type Check · consumer gates · Type Check · debt ledger
根因 —— 一条,不是十六条
十六条全部塌在同一个包的 DTS 构建上(Failed: @objectstack/platform-objects#build),错误逐字:
src/pages/sys-user.page.ts(88,9): error TS2322: Type '{ dialect: "cel" | "cron" | "template";
source?: string | undefined; ast?: unknown; meta?: {...} | undefined; }'
is not assignable to type 'string | { dialect: ...; source: string; ... } | undefined'.
Types of property 'source' are incompatible.
Type 'string | undefined' is not assignable to type 'string'.
⇒ 这正是本 PR 的收窄本身:EvaluatedExpression* 要求 source: string,而 packages/platform-objects/src/pages/sys-user.page.ts:88 仍然递进一个 source 可选的信封。一个第一方消费者没有跟着改。
@objectstack/platform-objects#build 一挂,Build Core、三条 Type Check、六个 Test Core 分片、四条 Dogfood 全部连带塌掉。修好这一处,十六条应当一起回绿 —— 这是预测,⛔ 不是读数,由补丁轮实测。
这恰恰是收窄该有的样子,⛔ 不是复核失职
一次契约收窄本来就该把不合规的调用点照出来。复核测的是「收窄是否正确且已声明」——它是;CI 测的是「谁在用旧形状」——sys-user.page.ts:88 在用。两个读数都对,合起来才是完整判断。本 PR 声明了破坏性变更却没有修自己仓内的消费者。
处置
- 契约复核 PASS 照记(
5715671761),⛔ 不撤、⛔ 不改。 - 本 PR 进补丁轮,原因是 CI 红,⛔ 不是契约 FAIL。
needs:contract-review继续挂着,⛔ 不在此刻摘除。理由说明白以便审计:规则写「PASS ⇒ 剥标、ready、auto-merge」,那是一个以「PR 可落地」为前提的动作单元;本 PR 不可落地,且补丁轮若动到契约面就欠一次 delta 复核。轮次未完,载体不摘。--pair 18638仍读 exit 4(C5)。复核已认定两条 tell 为假、修矫正器归 [finding]check-widening-tells.mjsreports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640、本 PR ⛔ 不得翻Clause-②: yes。⚠️ 本席补记一条给接 [finding]check-widening-tells.mjsreports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640 的人:该卡目前是未定级的finding(只有finding一个标签),按派发规则未定级的 finding 不可派发,归分诊席。⇒ 在 [finding]check-widening-tells.mjsreports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640 被定级并落地之前,本 PR 的--pair读数不会自己变 0;这是本 PR 之后还会撞上的第二道门,现在记下来,免得补丁轮回绿之后再被它挡一次。
Generated by Claude Code
…aluated-slot-narrowing Conflict resolved by hand in packages/spec/src/system/metrics.zod.ts: both intents stack — main's new DurationSeconds import plus this branch's swap of ExpressionInputSchema for EvaluatedExpressionInputSchema and the union refusal helper. ExpressionInputSchema has no remaining use in the merged file. The two both-sides-edited os-regen artifacts take main's side in this commit; the regeneration follows as its own commit. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Discharges the os-regen deferral the merge commit recorded. Both pages carry main's incoming content plus this branch's narrowing: the evaluated-slot envelope now prints `source: string` instead of `source?: string`, and metrics.mdx keeps main's `window.durationSeconds` rename. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Clause-② 复核记录 —— 合并增量复核,PASS
为什么范围是 8 而不是整条二点差分
五问读数(每问都带已点亮的控制项)
复核方自报并当场改正的一处仪器错误首轮「51 条同 blob」扫描曾误标 5 条为 MAIN CHANGED: 与本 PR 正文的关系正文〈Base catch-up〉小节里的数字与本复核独立测得的一致(17142+121+74=17337、3750+4+45=3799、计数器 202/553/367/3)。 ⛔ 复核方未推送、未改 PR、未动工作树。 Generated by Claude Code |
…/main Discharges the os-regen deferral from the prior merge commit. root.txt and shared.txt now reflect main's #18638 evaluated-expression-slot narrowing (source becomes required across the composing slots) plus the drift picked up while catching this branch up to main's current tip. No hand edits.
Main shipped the identical /`expression` return-type narrowing to EvaluatedExpression first, under #18638 (card #15811) -- confirmed by the merge: both sides made the same change independently, and ADR-0137's own status line says its PR carries no schema change. Re-announcing that narrowing here would duplicate #18638's own changeset entry in the same release. Drop the redundant paragraphs and keep only what #17778 alone ships: the ADR-0137 predicate fault-semantics contract and its ADR-0089 addendum.
… set that gained a value (objectstack-ai#18724) Part of objectstack-ai#18640 Clause-②: no `scripts/pm/check-widening-tells.mjs` raised **T2** — "a new member of a closed set" — on a line that appends a zod options object to a union whose member list is unchanged. The card measured it at `packages/spec/src/ui/action.zod.ts:833`, where `const ActionConditionInputSchema = z.union([z.boolean(), ExpressionInputSchema]);` grows a `, { error: … }` argument. The set carries the same arms before and after. This PR repairs that row and pins the shape. It deliberately does **not** repair the card's second row; see "The half that stays open" below. ## The control set IS the finding Four probes, one synthetic file on the judged surface, run as `node scripts/pm/check-widening-tells.mjs --declaration no --diff …`. Exit codes read from `$?` after a redirect, never through a pipe. | the edit | spelling | BEFORE (`a7e9a6600b`) | AFTER (this branch) | |---|---|---|---| | options object appended to an existing union | `const` binding, members INLINE | **exit 4, one T2** | exit 0 | | the identical edit | `const` binding, one member per line | exit 0 | exit 0 | | the identical edit | a KEYED property, members inline | exit 0 | exit 0 | | the same append **plus a third arm** | `const` binding, members inline | exit 4, one T2 | **exit 4, one T2** | Row 1 against rows 2 and 3 is the whole finding: one semantic change, three spellings, two opposite verdicts, decided by nothing but where the author put the newlines and whether the set is bound to a name or to a key. Row 4 is the counterfactual — a real arm added beside the options object still reports, with its own file:line. ## Why no reading in the file could already see that line - **objectstack-ai#16822's `rewritesExistingOpener`** reads an opener-ONLY line by construction. Its pattern refuses a line that already carries members, on purpose, so an inline opener was never in its population. - **objectstack-ai#16943's replacement budget** cannot reach the line either: `memberTellKind` answers `null` for `const C = z.union([…])`, which names no key and is no bare element, so the row neither earns on the removed side nor spends on the added one. The line therefore falls through every reading in the file to the raw `CLOSED_SET_OPENER` test, which is the whole of the tell for it. ## What was added `closedSetBindingMembers` (the binding a closed-set declaration names, plus the members the same line carries) and `respellsExistingClosedSetBinding`, consulted in the opener branch. The evidence is positive, block-local and absent by default, like every decline in this file: the block removed a line declaring the SAME binding, both member lists are readable inline, and the added list adds no NET member. The arithmetic is objectstack-ai#16943's own — "the ruling this implements is replacement-vs-net-addition, not spelling" — applied to the members the inline line carries, so this reading is **bounded exactly by what the multi-line spelling of the same block already does**, and by nothing wider. Three guards keep it that narrow: a KEYED line is refused outright, an ANONYMOUS set (no binding) is refused, and a list that does not close on the line is unreadable and keeps the tell. `keyedClosedSetMembers` is refactored onto a shared `inlineClosedSetMembers` reader so the keyed spend and the binding spend can never disagree about which members a line carries. That half is behaviour-preserving: the 381 pre-existing self-test cases pass unchanged. ## The half that stays open — and why this PR does not buy it The card's second row, `packages/spec/src/ui/component.zod.ts:1595`, is a **keyed** line whose union member was **renamed** (`ExpressionInputSchema` to `EvaluatedExpressionInputSchema`). It fails objectstack-ai#17618's fact 3 — "the added list a SUBSET of the removed one" — and it still reports. Measured: `--declaration no` over PR objectstack-ai#18638's own pushed bytes goes from two tells (exit 4) to one tell (exit 4). That row is **not** an accidental-spelling variable. The keyed population has no control bounding a relaxation: a keyed value whose list opens on a later line is unreadable and reports too, measured on the same harness. Whether a renamed member should defeat a subset test is a ruling about fact 3 — a ruling that would necessarily also silence a member SWAPPED for a wider schema, because no line-shaped matcher can tell a subtype from a supertype. The card's own direction rule is quoted verbatim rather than paraphrased: >⚠️ 注意方向:**拒绝过多的门禁是吵但安全的;拒绝过少的那个才危险。** ⛔ 任何修复都不得靠**普遍放松**矫正器来买安静。 So it is left to a decision, not taken here. PR objectstack-ai#18638 stays where it is; that outcome is acceptable and a clause-② gate that under-reports is not. ## Evidence **Self-test** — `node scripts/pm/check-widening-tells.mjs --self-test`: exit 0, **403 cases pass** (381 before this branch). The new battery is `objectstack-ai#18640 — an inline closed set RE-SPELLED at the same binding is not a set that gained a value`, floor 20, registering 22: the control set, the specimen, the counterfactual (an arm added beside the options object), and the dark, different-binding, brand-new, widened-enum and new-key controls that must all still report. **Ablations** — each leg mutated the file on disk, proved the mutation landed by an anchor occurrence count plus a `git hash-object` comparison against the HEAD blob, ran the self-test, then restored with `git checkout HEAD -- …` and proved the restore by an empty `git diff HEAD` and a blob hash equal to HEAD's: | leg | self-test | cases that turned red | |---|---|---| | the opener-branch wiring removed | exit 1 | 4, headed by the specimen and the clean-pair case | | the net-delta arithmetic replaced by an unconditional accept | exit 1 | 5, headed by **the counterfactual** and the widened-enum control | | the keyed-line guard dropped | exit 1 | 1 — the case pinning that the two populations cannot merge | Leg 2 is the one that matters for direction: if a later author loosens the arithmetic, the counterfactual turns red. **Price** — measured at `222ef3f1ee` (this branch's head) over the 749 commits touching the judged surfaces in the history provably present in this tree (`node scripts/pm/git-history.mjs ensure --days=30`: floor 2026-08-11, tip 2026-09-17; the clone is shallow and the window is stated because a partial is not a zero). Of the 20,452 tell rows the previous reading raises, **20,452 stand and 0 move**. 55 of the 3,902 T2 rows in that window sit on a line this reading can READ, and it declined none of them — none had a same-binding removal in its own change block. **No row anywhere in that window begins reporting.** Positive control on the harness itself: the same two modules over PR objectstack-ai#18638's diff read 2 rows before and 1 after. **Lint** — a DECLARED narrowing, not a whole-repo run. `pnpm exec eslint --no-inline-config --format json scripts/pm/check-widening-tells.mjs`: exit 0, **1 file, 0 errors, 0 warnings** (file count read from the JSON). The population `eslint .` would check, read from eslint's own config rather than guessed — `isPathIgnored` over every tracked lintable-extension file — is **6,818 files, 0 of them ignored**. The narrowing is sound because this repo's single `eslint.config.mjs` enables no type-aware linting for ANY file (its own comment: "no `parserOptions.project`, no typed `@typescript-eslint` rules"), so a one-file diff cannot move the verdict on any file it did not touch. The whole-repo run belongs to CI. **Population** — on the tree at `a7e9a6600b` plus this branch: 50 lines in 31 files of the 998 judged source files are non-keyed inline closed-set declarations with a named binding, against 666 keyed inline sets (objectstack-ai#17618's population, untouched) and 311 opener-only lines (objectstack-ai#16822's population, untouched). ## The quiet direction this buys A one-for-one member SWAP at an existing binding — `z.union([A, B])` to `z.union([A, C])` — now declines, and `C` may accept more than `B` did. That is not a new class: objectstack-ai#16943 bought exactly that silence for every set spelled one member per line and measured it over 82 commits (34 declines, not one of them a member rename). This removes the accidental exception, not the rule. What still catches a swap that slips past is what caught it for the spelled-out form: `check:api-surface` on any exported name it moves, `check:authorable-surface` on any authorable key it changes, and the ADR-0087 registries. The OVERTURN CONDITION is written into the docblock: the first LANDED widening carried by a same-binding inline member swap closes it by reading the members' own declarations. ## Not a changeset `scripts/pm/**` ships in no package's `files[]`, so this publishes nothing — `skip-changeset`. ## Acceptance notes - **To file (3 classes).** `respellsExistingClosedSetKey`'s fact 3 reports on a KEYED closed-set value whose member was RENAMED — reproducible at `packages/spec/src/ui/component.zod.ts:1595` on PR objectstack-ai#18638 and on a synthetic probe. Class (a), and it needs the fact-3 ruling above before a repair can be written. Dedupe words: `widening-tells keyed subset rename`, `objectstack-ai#17618 fact 3 member rename`, `respellsExistingClosedSetKey subtype`, `clause-2 keyed union arm renamed`, `component.zod visible union rename`. - **Noted, not filed.** The self-test verdict sentence does not enumerate every declared battery (objectstack-ai#18560's is absent from it). Cosmetic prose drift with no reader; a clause for this round was added while the sentence was open. Successor: the next round that edits the sentence. - **Noted, not filed.** `closedSetOpenerBinding` accepts an EMPTY binding while `closedSetBindingMembers` refuses one. The asymmetry is deliberate here (an anonymous inline set has no declaration identity) and objectstack-ai#16822's half was left untouched. Successor: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…asure — narrow `DashboardWidgetSchema.values` for the metric/kpi/gauge/solid-gauge/bullet family (objectui#8894 ruling D) (objectstack-ai#18720) Fixes objectstack-ai#17779 Clause-②: yes (narrowing) Executes maintainer ruling **D** on objectui#8894 (decision batch objectstack-ai#119 item 4, 2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 — judge the protocol wrong: a metric-family widget takes exactly one measure. The direction was not re-opened here. ## What changed `DashboardWidgetSchema.values` was `z.array(z.string()).min(1)` with **no upper bound on any widget type**, so a `metric` tile could declare three measures; the dataset query selected and computed all three and the tile rendered `values[0]`. The other two were queried and dropped on the floor (objectui#7293 defect 1). objectui#8887's sub-caption made the tile honest about dropping them; it did not make the document legal. - `checkDashboardWidgetMetricMeasureArity` — a new exported object-level check, chained onto the same door by identifier, refusing more than one measure on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` and on a widget that declares no `type` (it defaults to `metric`, and the message says so rather than claiming the author wrote it). One `custom` issue at `values`, naming the widget's `id`, the count, and the authored `type`, and prescribing one measure per tile — "make N tiles for N measures" — plus the visuals that DO render several numbers. - **Exactly one is a conjunction**: the field's own `.min(1)` still owns the empty array (`too_small`, unchanged, and the new check deliberately adds no second issue there); the new check owns the upper bound. - `.changeset/17779-...` — `minor`, **BREAKING** banner, ADR-0087 disposition `registered dashboard-widget-metric-family-multi-measure-refused`. - `packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts` — one new entry file, plus the `gen:migration-registry` lap. No other file in that directory was touched and nothing was hand-edited inside the generated regions of `registry.ts`. - The `values` doc string now states the arity rule it enforces, so the generated reference page stops saying only "at least one". ## The three questions the dispatch asked, answered by measurement ### 1. `superRefine`, not a per-type union arm — because a union destroys every other diagnostic on this door Eight widget bodies through `z.union([metricArm, otherArm])` versus one more `.superRefine` on the strict object, measured on this tree: | body | union arms | the spelling shipped | |---|---|---| | `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the strict-object refusal, naming the key + the history sentence | | `categoryField` / `valueField` | `(root) invalid_union: Invalid input` | the `WIDGET_GUIDANCE_SETS` ADR-0021 prescription | | `titel` | `(root) invalid_union: Invalid input` | "Did you mean `titel` → `title`?" | | `type: 'ziggurat'` | `(root) invalid_union: Invalid input` | `invalid_value` at `type`, listing all twenty | | `metric` + 3 measures | `too_big` at `values` | the curated `custom` refusal at `values` | Four of eight bodies lose their whole diagnostic to one bare `Invalid input`. That is not a new observation on this file: the `compareTo` docblock already records it for the same reason (objectstack-ai#5014 — "a union collapses into one bare `Invalid input` on the wire … A plain strict object's errors reach the author"), and `view-union-diagnostics.test.ts` is the entire apparatus objectui needed **because** `ViewMetadataSchema` is a union. A second union here would commission that apparatus again to buy a refusal the object-level form gives for free. Second datum, measured: zod 4.4.3 throws `Cannot overwrite keys on object schemas containing refinements. Use .safeExtend() instead` on a plain `.extend()` that redeclares a key, so the arms cannot even be built from the existing door without `.safeExtend()` or a duplicated declaration. ### 2. `major` does collide with `check-changeset-no-major` — so the changeset is `minor` The guard is **armed**: there is no `.changeset/pre.json`, so the RC exemption does not apply, and the only other route is the `allow-major` PR label whose own error text says "a whole-stack major release is genuinely intended" — false for this PR. Its header states the convention: every publishable package is in the Changesets `fixed` group, so one `major` promotes all ~70 packages; during the launch window a breaking change ships `minor` and **breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the bump level**. `pr-automation.yml`'s "WHICH LEVEL" prose says the same in the place the author reads it. So the card's "major changeset" is satisfied as `minor` + `**BREAKING**` + `registered ...`, and `check-adr-0087-registration --base origin/main` reads the changeset back as `[BREAKING+bang+clause-②-narrowing] registered dashboard-widget-metric-family-multi-measure-refused`. ### 3. The migration entry's acceptance criteria, re-derived from what the code refuses Not a restatement of the card. Two things the card's wording implies that the machinery does **not** do, both measured and both written into the entry: - **The TODO cannot name your dropped measures.** `applyMetaMigrations` maps `step.semantic` straight onto the result (`chain.ts`) with no per-document interpolation and no filtering by whether the stack even carries the shape, and `SemanticMigration` has only static string fields. `os migrate meta` therefore prints the entry's prose, not a list. The **refusal** is what names them, per widget, on the re-parse — so the entry tells the author to drive the fix off `os build`, not off the migrate output. - **Splitting into N tiles is not attempted**, as the card says — and the entry states why in the registry's own terms: N tiles need N ids and N boxes on a 12-column grid, which is a layout fact about a dashboard the registry has never seen. The rest of `acceptanceCriteria` is the measured accept/refuse matrix: which door refuses (publish, not objectui's `.shape`-mirror editor), the empty-array carve-out, the aborting `invalid_value` on an unknown `type`, the un-reachable "does this measure exist in the dataset", and the fact that `.omit()` / `.pick()` / `.partial()` already threw before this change. ## Controls **LIT** — a legal single-measure metric tile parses **identically before and after**, and the non-metric families are untouched. Sixteen bodies through `DashboardWidgetSchema.safeParse`, before and after the change: | body | before | after | |---|---|---| | `metric` + 1 measure | ACCEPT, `values: ["amount_sum"]` | ACCEPT, `values: ["amount_sum"]` | | `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` + 2–3 measures | ACCEPT (all five) | REFUSE `values:custom` (all five) | | no `type` + 3 measures | ACCEPT, `type: "metric"` | REFUSE `values:custom` | | `bar` / `line` / `table` / `pivot` / `funnel` + 3 measures | ACCEPT | ACCEPT (unchanged) | | `metric` + `values: []` | REFUSE `values:too_small` | REFUSE `values:too_small` (one issue, not two) | | `type: 'ziggurat'` + 3 measures | REFUSE `type:invalid_value` | REFUSE `type:invalid_value` (alone) | | `metric` + 3 measures + `bogusProp` | REFUSE `unrecognized_keys` | REFUSE `unrecognized_keys` | The whole taxonomy is covered by a pin that asserts the metric family plus the fifteen others **is** `ChartTypeSchema.options`, so a new chart type cannot land uncovered by either list. **DARK** — things that must read **0**, with paths and counts: - `.min(1)` **array** keys in `packages/spec/src/ui/dashboard.zod.ts` other than `values`: **0**. The file has exactly two `.min(1)` code sites at the branch point — `values` (line 706) and `dashboard.columns` (line 1151, `z.number().int().min(1).max(24)`, a number bound, not an array). The latter is byte-identical after the change; every other new `.min(1)` occurrence in the file is inside a docblock. - `ReportSchema.values` (`packages/spec/src/ui/report.zod.ts`, lines 237 and 314) is a separate declaration, `optional()`, with no `.min(1)` and no arity check, and its `type` enum (`tabular` / `summary` / `matrix` / `joined`) contains **0** metric-family members. Untouched, and not the same defect. - Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` / `.md` / `.yaml` **at the branch point** `72dd95fa5a`: **187** brace-local literals carrying a `values: [...]`, **39** of them on a metric-family `type` (both lit controls), and **0** of those carrying more than one measure. Nothing in the monorepo moves. On this branch the same scan reads 205 / 49 / **7**, and all seven are the fixtures this PR added. - `check:authorable-surface` is green with no regeneration: **0** authorable keys move. `check:api-surface` reports `0 breaking (removed/narrowed), 1 added` — the new exported check. ## Verification Red before green, with the mutation proved on disk and the restore hash-verified: ``` HEAD blob : 30c6d78 worktree blob : 30c6d78 (at HEAD before the mutation) anchor occurrences BEFORE: 1 AFTER: 0 injected line: 1 mutated blob : 90548649227c3971f16b7dc85b02e1bab8155f96 (differs -> the edit really landed) RED vitest exit=1 17 failed | 205 passed (222) restored blob : 30c6d78 git diff HEAD on the path: empty GREEN vitest exit=0 222 passed (222) ``` The mutation removed only the `.superRefine(checkDashboardWidgetMetricMeasureArity)` attachment, leaving the function declared — so the 17 reds are the door's behaviour, not a compile failure. The script carried a `trap ... EXIT INT TERM` restore against an absolute `git rev-parse --show-toplevel` path, restored with `git checkout HEAD -- path` (never a bare `git checkout --`), and proved the restore by blob hash **and** an empty `git diff HEAD`. - `pnpm --filter @objectstack/spec test` — **486 files / 13933 tests passed**, exit 0. - `pnpm --filter @objectstack/spec typecheck` — exit 0 (`check:scripts-typecheck` and `check:test-typecheck` included; the test-layer ledger held at 54 files / 259 errors / 144 pinned signatures, shrink-only). - `pnpm --filter @objectstack/spec check:generated` — **all 15 generated artifacts up to date**, exit 0, after regenerating exactly the three it proved stale (`api-surface/`, `export-origins/`, `content/docs/references/**`). - Changeset gates: `check-adr-0087-registration --base origin/main` exit 0 (+ `--self-test`, 384 assertions), `check-changeset-no-major --base origin/main` exit 0, `check-empty-changeset --base origin/main` exit 0. - `pnpm check:nul-bytes` exit 0 (8812 text files, no raw control bytes), plus `check:widget-option-census`, `check:liveness`, `check:exported-any`, `check:dual-source-exports`, `check:entry-nameability`, `check:empty-state`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:merge-driver`, `check:pm-widening-tells`, `check:spec-docblock-symbol-anchors`, `check:dts-closure`, `check:published-files`, `check:spec-parsed-alias`, `check:page-declaration-shape`, `check:corpus-claim-drift`, `check:skill-examples`, `check:docs-transcript-drift`, `check:doc-formula-expressions`, `check:variant-docs`, `check:llms-txt`, `check:yaml-examples`, `check:objectui-pin-citations`, and the ten doc gates the regenerated `.mdx` newly derives — every one exit 0. - **Repo-wide lint, not a narrowing**: `node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json` at `ea17ab8491`, 81s — **6822 files linted, 0 errors, 0 warnings**, exit 0. ## Migration-entry adjacency — checked, not assumed `packages/spec/src/migrations/entries/` is one file per entry and the entries README records the measured objectstack-ai#8344 table: two in-flight registrations merge clean **unless** their ids are adjacent in sort order or both are the first entry of a new major. Enumerated the `18.*` semantic directory and every open PR's file list on 2026-09-17: - In-flight ADDED semantic registrations: `ui-list-view-groupbyfield-padded-refused` (objectstack-ai#18695), `structured-region-body-pause-and-end-refused` (objectstack-ai#18688), `evaluated-expression-slots-source-required` (objectstack-ai#18638), `manifest-id-reverse-domain-required` (objectstack-ai#18319). (objectstack-ai#18420 modifies an existing entry, which is not an insertion.) - This entry's immediate neighbours in the sorted set are `dashboard-header-modal-target-page-only` and `dashboard-widget-stage-order-non-funnel-refused` — **both already landed on `main`**, neither in flight — and it is not the first entry of major 18. Neither ejection row applies. The seat's expectation about objectstack-ai#18695 held, and was verified rather than assumed. The two projections the README names came back **byte-identical, and that is correct rather than a skipped step**: `build-spec-changes.ts` and `build-upgrade-guide.ts` both loop `for (major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++)`, and `PROTOCOL_MAJOR` is **17** while this entry registers under **18**. Both were regenerated anyway and `check:spec-changes` / `check:upgrade-guide` are green. ## Acceptance notes Noted, not filed — neither is a reproducible defect, a contract violation, or a metadata-authoring trap: - **zod 4.4.3 refuses `.extend()` that overwrites a key on a refined object** ("Use `.safeExtend()` instead"), measured here while probing the union spelling. It is a trap for the next author who mirrors or re-arms this door — recorded in the new check's docblock and in the migration entry, which is where that author looks. Successor: whoever lands objectui#8894's half, which must re-attach this export onto a `.shape` mirror. - **An ADR-0087 semantic entry cannot name per-document values.** `applyMetaMigrations` emits `step.semantic` unconditionally and `SemanticMigration` carries only static strings, so a card instruction of the form "emit a structured TODO naming X" is unsatisfiable as literally written — the refusal message is the only per-document channel. Recorded in this entry's `acceptanceCriteria`. Successor: the next card that writes that instruction. ## Downstream, not in this PR Card item 3 (objectui's contract twins gain the refusal pin; the runtime warning becomes the door refusal) is the objectui half and objectui#8894 is `pm:blocked` on this card. Nothing in `../objectui` was touched. Until that package imports and chains `checkDashboardWidgetMetricMeasureArity`, its `.shape`-mirror editor keeps accepting three measures on a `metric` and the author meets this refusal at publish — stated in the check's docblock and in the migration entry rather than left implied. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --- > ⏱️ **席位代改正文(dev 只写一次,⛔ 不 PATCH 正文;事后要改的由本席代写)。** 两处: > > - **`applyMigrationChain` → `applyMetaMigrations`(2 处)** —— 前者在树上**不存在**; > 真函数是 `packages/spec/src/migrations/chain.ts:68`,CLI 调它,根 api-surface 导出它。 > 同一处错名也写进了 ADR-0087 语义条目、并经 `gen:migration-registry` 复制进 > `registry.ts:6765` —— 那段文本会被 `os migrate meta` 在协议 18 打印出来, > 所以读者照着 grep 会一无所获。已随 `3b15ca1254` 修正(条目 + 重生成,⛔ 未手改 registry.ts)。 > ⭐ 这一条由**达档隔离契约复核**判出(记录见下方 PASS/FAIL 评论),⛔ 不是本席自己看出来的。 > - **`Clause-②: no (narrowing)` → `yes (widening)`** —— 该行**只定路由**,⛔ 非终审: > 章程原文「只定是否必过席内契约复核的保守方向」,机械地板「新导出符号…恒 `yes`」。 > 本 diff 在 `api-surface/ui.json` 上**净增一个导出符号** > (`checkDashboardWidgetMetricMeasureArity`,+1 / 移除 0,本席对着 merge-base `72dd95fa5a` 实测), > ⇒ 地板落在 `yes`。认领侧早已是 `yes (widening)`,正文与 changeset 两个载体**落后于它**; > changeset 已随 `881db1280d` 对齐,并在行内写明两条轴(接受集**收窄**、公开面**扩大**), > 免得 CHANGELOG 读成「本改动放宽了行为」。 > >⚠️ **破坏性未受影响**:`check-adr-0087-registration` 仍读作 breaking, > 经 `**BREAKING**` 横幅与摘要里的 `!`;它失去的 `clause-②-narrowing` 信号从来不是唯一载体 > (实测 `[BREAKING+bang]`,exit 0)。 > > ⏱️ **再正一次(席位):`yes (widening)` → `yes (narrowing)`。** 上一版本席以为「收窄行为 + 扩大公开面」在这套两态词表里没有正确拼法,于是取了 `widening` 并写了一段话解释「它不是那个意思」。**那个前提是错的**:`readClause2Line` 认 `yes (narrowing)`,而`check-adr-0087-registration` 的自测逐字命名了这个形状 ——「the `narrowing` arm beside a `yes` value — a diff that **widens AND narrows**」。⇒ 值仍是 `yes`(机械地板:新导出符号),但**臂**改回 `narrowing`,`clause-②-narrowing` 信号随之回到 ADR-0087 门禁(实测 `[BREAKING+bang+clause-②-narrowing]`,exit 0)。⭐ 这一条由第二次达档复核在 ③ 里作为**边界旗标**提出,⛔ 不是 FAIL;本席自己验过词表才动手。⚠️ 顺带一提 `no (widening)` 读作 **malformed** —— 臂不是自由的:`no` 只配 `narrowing`,`yes` 两者皆可。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ast 100 is UNJUDGED rather than a truncated claim pool (objectstack-ai#18799) Fixes objectstack-ai#18683 Clause-②: no ## The defect `scripts/pm/check-clause2-carriers.mjs` read a card's comment thread with ONE request — `/issues/{n}/comments?per_page=100`, no `page=` ladder, no short-read check — while the two sibling list reads in the same file paged to a declared cap and answered `null` (UNJUDGED, never clean) when they hit it. One file, two OPPOSITE defaults on "I did not read everything", and the fail-OPEN one was the read that arbitrates OWNERSHIP: the governing-claim pool, its membership, and the `Clause-②` declaration read out of it all come from those rows. A thread past 100 comments handed the pool its first page and nothing said the tail had been dropped, so a claim written past row 100 was not superseded — it was never a candidate — and a superseded carrier governed in its place. ## The before-reading, on a 101-row fixture Driven end to end through the real CLI against a stubbed board (`--pair`, no network), on `main` `d9ba33df4c` (script blob `d753e2a8cf06d8f72c436e0a1917b2fecb8be813`). Two fixtures, both 101 rows, both differing from a complete thread only past the page boundary. | fixture | BEFORE (`main`) | AFTER (this PR) | |---|---|---| | 100 claim-free rows, the 101st the only `Claim:` | `card-comments: 100 row(s)` · `claim.selected: none — no comment on this thread carries a line beginning \`Claim:\`` · **exit 4, row C2 `absent`** | `card-comments: 101 row(s)` · the 101st claim is the pool · `claim.clause2-line: DECLARED \`no\`` · **exit 0** | | row 1 an older `Claim:` declaring `yes`, the 101st a newer one declaring `no` | `claim.clause2-line: DECLARED \`yes\`` from the SUPERSEDED carrier, which is not even listed as rejected · **exit 4, row C3** | the newer claim governs, the older is listed REJECTED/SUPERSEDED · `DECLARED \`no\`` · **exit 0** | The second row is the fail-OPEN direction stated as a measurement: one thread, two readings, and they disagree on the declaration itself. ## The ladder, and the cap All three list reads now go through one `pagedListRead` helper — it pages to a declared cap, stops on the FIRST short page (no wasted request), and on the cap files the one shared `pageCapNote` sentence and answers `null`. `readCarrierEvents` (`EVENT_PAGE_CAP` 10) and `readPullFiles` (`FILE_PAGE_CAP` 3) keep their caps to the number; what they gain is that the third read can no longer hold a different default. `COMMENT_PAGE_CAP` is **10** pages = 1,000 comments. Sized on this board, read 2026-09-17 off the open-issue list rows (550 rows listed, cross-checked against `open_issues_count` = 550): - longest open thread of any kind: seat post objectstack-ai#6015 at **895** comments — nine pages; - next four: objectstack-ai#12708 at 365, objectstack-ai#6023 at 241, objectstack-ai#6017 at 206, objectstack-ai#6024 at 187; seat post objectstack-ai#7623 at 71; - longest thread carrying a queue label: objectstack-ai#13799 at **117** (`pm:queue`, p2, unassigned); - longest card in the clause-② population (28 pairs the sweep derived that day): objectstack-ai#17534 at **14**. So ten pages clears the whole board today with a page to spare, and it is the same ten `EVENT_PAGE_CAP` uses — a reader comparing two caps in one file should have to remember one number. ## The input record The diagnosis key stays `comments`, so every sentence already keyed to it still finds its diagnosis. Two declared fields are added to `INPUT_RECORD_PAIR_FIELDS`, one per thread this file reads: ``` pair.1.card-comments: 101 row(s) pair.1.card-comment-pages: 2 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE pair.1.pr-comment-pages: 1 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE ``` and, when the cap is what stopped the read: ``` pair.1.card-comment-pages: CAPPED — 10 of 10 page(s) of 100 comments each were requested and EVERY ONE came back full, so the tail is past the cap and the thread is UNREAD (UNJUDGED) — ⛔ never a truncated pool, ⛔ never a clean reading ``` A thread of exactly 100 rows and a thread whose tail was dropped are the same `100 row(s)` in every other line the block prints; they differ here, because the complete one stopped on a short page and the truncated one did not stop at all. The request ledger PR objectstack-ai#18681 added shows the same ladder from the other side — request objectstack-ai#3 is now `…/comments?per_page=100&page=1` and objectstack-ai#4 is `&page=2`. ## The pins A new `--self-test` battery, `objectstack-ai#18683: the card-comment read pages to a cap — past 100 is UNJUDGED, ⛔ never a truncated pool`, 27 cases, declared in `SELF_TEST_BATTERIES` with the roster floor raised 29 → 30. It drives the ladder with an offline page server that reproduces GitHub's own semantics and counts the requests; ⛔ no network. What it holds: the 101st claim ENTERS the pool and GOVERNS, and its line is what the limb reads; the same thread cut at 100 reads `absent` (the CONTROL — the reading the un-paged read produced); the newer claim past the boundary supersedes the older one inside it, and cut at 100 the superseded carrier's `yes` is what the limb reads; a capped read is `null`, which is neither `missing` nor `absent` nor a carrier but `unreadable`; the ladder stops on the first short page (2 requests for 101 rows, 1 for a short thread, 2 for exactly 100 — a full page is indistinguishable from a finished one); a page that came back unread ends the ladder and the record says the cap was NOT what stopped it; the input record declares and prints both ladder fields; the sibling caps are untouched; and all three reads render ONE cap sentence. ## The census, and the triage's upgrade probe Report-only, no state write. Over the 550 open rows (521 issues, 29 PRs) read on 2026-09-17: - open `pm:queue` / `pm:dispatched` cards: **274**, of which **1** exceeds 100 comments — objectstack-ai#13799 at 117; - all open issues over 100 comments: **8** — objectstack-ai#6015 (895), objectstack-ai#12708 (365), objectstack-ai#6023 (241), objectstack-ai#6017 (206), objectstack-ai#6024 (187), objectstack-ai#6021 (145), objectstack-ai#6367 (127), objectstack-ai#13799 (117). Seven are `pm:seat` posts; - open PRs over 100 comments: **0**; the longest is objectstack-ai#18638 at 10. **The upgrade probe's result: the condition is NOT met today.** The clause-② population is what a `--pair`/sweep derivation actually pairs, not what carries a queue label: the sweep derived **28 pairs from 29 open PRs**, and the longest card thread among them is **14** rows (objectstack-ai#17534). The two open PRs that mention a 100+-comment card in prose — objectstack-ai#18786 (objectstack-ai#6015, objectstack-ai#7623) and objectstack-ai#18765 (objectstack-ai#6024) — deliver objectstack-ai#18693 and objectstack-ai#18652 respectively, both under 10 comments; driven live before and after, both answer exit 0 with an identical pair reading. So no recorded `--pair` verdict on this board today was taken on a truncated pool, and the triage's p1 condition (「找到任一进入条款②认领池、评论数 > 100 的卡并驱动一次」) has no live instance to drive. The exposure is one PR away rather than realised: objectstack-ai#13799 is `pm:queue` at 117 and enters the population the moment a PR delivers it. The cost is unchanged by the ladder, measured on the same board: **64 reads for 28 pairs, before and after**, because every live thread fits one page and the ladder stops on a short page. The live `--pair 18765` input records differ in exactly three lines — the two request paths gaining `&page=1`, and the two new ladder fields.⚠️ One thing the two full sweeps do NOT compare: the sweep's finding COUNT moved 4 → 3 between them, and that is the board, not this diff. `needs:contract-review` was hung on PR objectstack-ai#18792 at `2026-09-17T21:04:46Z`, between the two runs, closing the C1 split on objectstack-ai#18792 / objectstack-ai#17541 on its own. The controlled A/B is the `--pair 18765` diff above. ## The ablation Two legs, each from the COMMITTED fix, each proving the mutation reached disk by blob hash and occurrence count before reading any result, each restored under a `trap` with `git checkout HEAD --` and verified by hash and an empty `git diff HEAD`. HEAD blob `ccd5ad7c9a00fe703d644be261a24f1ed847915a`. | leg | mutation | blob after | self-test | |---|---|---|---| | A — the ENTRY side | `COMMENT_PAGE_CAP` 10 → 1 | `1a0a952d07748101937420006b9475042d93a5cb` | **exit 1, 11 of 865 red** — the 101st-claim pins, the superseding pins, the request-count pins, the input-record pin | | B — the UNJUDGED side | the cap branch returns the pages that DID arrive (the pre-fix fail-OPEN default) | `c176dfe7e54e7de6bc45737487841a346f509b79` | **exit 1, 3 of 865 red** — a capped read is no longer `null`, no longer `unreadable`, and files no cap sentence | Every red in both legs belongs to the new battery; nothing pre-existing went red in either. A third, unplanned reading came for free: leg B's first attempt was a `perl -0pi` substitution whose anchor contained a `/`, so the edit silently did nothing — the on-disk proof refused it with `ABLATION VOID: the edit did not reach disk` instead of reporting a green as a measurement. ## Self-test ``` ✓ check-clause2-carriers self-test: 865 cases pass ``` 838 before, 865 after — the 27 the new battery registers, which is what its floor pins. ## Derived gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no hand-fed path list, re-derived after each `origin/main` merge (identical list both times). All 34 run at head `993cb89e18`, each exit code captured by redirect-then-`$?`: ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-changeset-no-major.mjs --self-test :: exit 0 node scripts/check-ci-filter-parity.mjs :: exit 0 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/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-scripts-symbol-anchors.mjs :: exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-clause2-carriers :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm lint :: exit 0 ``` `--ran` reconciles 34 derived / 34 run / 0 UNRUN. `pnpm check:pm-dispatch-gates` was run detached to a file — 1,788 cases, 748.6s on this box — and waited on in the foreground rather than under a timeout, so it is a measurement and not a SIGTERM. ## Out of scope, deliberately objectstack-ai#18764 (a decorated `**Claim:**` never enters the pool — the ENTRY side) was read and NOT folded in: this card is WHICH rows reach the reader, not what the reader does with them, and the two repairs touch different lines. `claimRetractions` (PR objectstack-ai#18770, the EXIT side) was read for the words it uses and not touched. The header's request-budget paragraph is amended in the same commit, because it stated "2 reads per card" as a fact and the thread is now a ladder — a cost statement that stopped being true is the shape this file exists against. `skip-changeset`: `scripts/pm/**` ships in no package's `files[]`, so nothing published moves. --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ Co-authored-by: Claude <noreply@anthropic.com>
…t-map.map` back at it (objectstack-ai#18904) Fixes objectstack-ai#18406 Clause-②: yes (widening) — the published accept set grows by exactly one key, `ui/ListMapConfig:style`. Changeset: `@objectstack/spec` minor. Director decision batch objectstack-ai#153 item 4, letter 1 (ruling comment 5724940537 on objectstack-ai#18406, maintainer 「其他同意」). ⛔ Not `mapStyle` (letter 2 declined: "a second spelling the renderer would have to learn"). ⛔ No alias (letter 3 declined: "an alias is a permanent obligation for a key nobody has written yet"). ## The defect, measured `ListMapConfigSchema` is a `strictObject` and `style` was the one member of the map renderer's own documented config surface it omitted, so `ListMapConfigSchema.safeParse({ style: 'https://tiles.example/style.json' })` answered `success: false` and a map style could not be declared through the spec's list-view face at all. Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: | Reading | Where | | :--- | :--- | | `ObjectMapConfigSchema` declares eight keys, `style` among them | `packages/types/src/zod/objectql.zod.ts:562`, the `style` row at `:570` | | `getMapConfig` reads `schema.mapStyle || schema.map?.style` | `packages/plugin-map/src/ObjectMap.tsx:365` | | the authored block is validated against that schema | `packages/plugin-map/src/ObjectMap.tsx:373` | | objectui's own docs publish `style` inside the block | `content/docs/plugins/plugin-map.mdx:131` (that path is objectui's, and the spec docblock's parenthetical is scoped to objectui — verified present at the pin, so the citation is sound) | | `FLAT_MAP_CONFIG_KEYS` is that shape MINUS `style` | `packages/plugin-list/src/ListView.tsx:40-67` | So the divergence was against the documented surface the spec docblock itself cites, not merely against code.⚠️ Line numbers were re-derived from the tree. The card and the ruling both cite `view.zod.ts:1631` for `ListMapConfigSchema`; on this branch's base it is at **`:1754`**. ## The change, file by file **`packages/spec/src/ui/view.zod.ts`** - `ListMapConfigSchema` (`:1754`) gains `style`, an optional string, after `center`. The describe names it the map style URL, records that the component-level `mapStyle` is read FIRST and wins when both are present, and says it is not the inline CSS `style` record a component node carries. - The docblock's strictness paragraph (`:1746-1752`) gains a purely ADDITIVE correction. `strictObject` stays and the rationale for closing the block is untouched — the ruling does not fault strictness. What is recorded is that the paragraph's parity claim was one key SHORT, which key it was, and the pin it was measured at. **`packages/spec/src/ui/component.zod.ts`** - `map` (`:3272`) stops being `z.unknown()` and becomes `ListMapConfigSchema.optional()`, spelled exactly as the sibling `gantt` door is. `ListMapConfigSchema` joins the `./view.zod` import on line 4. The registration `'object-map': ObjectMapPropsSchema` (`:3735`) is unchanged. - The "VALUE posture for `map`" paragraph is rewritten: it was the record of WHY the door had to stay open, and that reason is now discharged. - The flat-guidance docblock gains "minus that same `style`" plus the reason `style` is subtracted on both sides — flattened to the top level it collides with `BaseSchema.style`, the node's inline CSS record, which is why the renderer stopped reading a top-level `style` as a map style at all (objectui#5017) and why the prescription routes it to `mapStyle`. - `mapStyle` is untouched. It is the component-level spelling, read first, and is not a member of the config block. **`packages/spec/src/ui/component.test.ts` — PR objectstack-ai#18403's negative pins, inverted (`:3534-3540`)** - `ListMapConfigSchema.safeParse({ style: ... }).success` flips `false` → `true`. - The door's acceptance of a `map` block carrying `style` stays `true`, now through the spec's own schema rather than through an open value. - ⭐ A third assertion is added, because the two above would pass just as well against the `z.unknown()` they replaced: an authored `map` block carrying `styl` is refused, and the refusal is asserted to land AT `map` with `styl` echoed in the issue's `keys`. - The flat-key-set equality (`:3507-3509`) now derives by SUBTRACTION — the config block's shape minus `style` — rather than by hand-listing, so a newly declared config key still lands in that assertion. **`packages/spec/src/ui/view.test.ts`** - The documented-surface test carries eight keys instead of seven, and asserts the parse OUTPUT equals the input, so a declaration that silently dropped a member could not satisfy it. - A new pin takes the card's repro as its subject: `style` accepted alone, the node-level object form of `style` refused, and both `styl` and `mapStyle` still refused — `style` did not open the block. **Generated (regenerated, never hand-edited)** - `packages/spec/authorable-surface/ui.json` gains exactly one line, `ui/ListMapConfig:style`. Written by `pnpm --filter @objectstack/spec build`. - `content/docs/references/ui/view.mdx` and `content/docs/references/ui/component.mdx` via `gen:docs`. In the component reference the `object-map.map` row changes from `any` to the block's real shape and a nested-shape table for it appears — the retraction of `z.unknown()` made visible in the published reference. - `authorable-surface.base.json` is deliberately NOT touched; only `gen:authorable-surface-base` writes it. Of the 15 generated artefacts, `check:generated` proved exactly one stale (`content/docs/references/**`) and `--fix` regenerated only that one. After the `origin/main` merge and a full rebuild, all 15 report up to date with a clean tree. ## Reverse verification **Ablation (PLANT mode, `scripts/ablation-replace.mjs`).** The `style` row was renamed to `styleAblated18406`, so the key is no longer declared under the name the pins assert. On-disk evidence: anchor count 1 → 0, planted marker 0 → 1, blob `1c83516951e3` → `d256ada1f791`. Restore proved by blob equal to HEAD, `git diff HEAD` empty, and a WHOLE-TREE `git status --porcelain` empty. - **Source leg — RED, as predicted.** 4 of 782 tests fail, and they are exactly the four this PR authors or modifies; 778 pass. So the new pins are discriminating, not vacuously true. - **Build leg — an independent third witness.** `gen:schema`'s authorable-key ratchet also reds under the ablation and names `ui/ListMapConfig:style` as having "disappeared from the contract" while the committed baseline still records it. That is mechanical confirmation that the key really landed in the tracked contract. - **Dist leg — NOT MEASURED, and declared so.** Because the ablated build failed at `gen:schema`, no ablated `dist/` was ever produced. The temporary type probe consequently read the PRE-mutation artefact and exited 0 — precisely the false green `scripts/ablation-dist-preflight.mjs` exists to catch, and it caught it, reporting the marker ABSENT from `dist/`. That green is discarded, not recorded as a pass. **Cross-package type reverse verification**, supplied separately because the dist leg above could not supply it. A temporary probe inside `packages/lint` resolves `@objectstack/spec/ui` through the package `exports`, i.e. the BUILT declarations, so its verdict is a function of build state: - Positive leg, exit 0 — `style` is assignable on the config block, AND a `ts-expect-error` directive on an undeclared member inside `map` is USED, which is only true if the door really narrowed away from `unknown`. - Negative leg, exit 2 — both halves fail as required, and each would have COMPILED against the pre-change declarations. The compiler names `bogus` as not existing in the block type and prints that type inline with its `style` member, and it reports the stale expectation-of-refusal as an unused directive. A cached declaration file cannot produce either error. Both probe files were deleted; the tree is clean. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **107** command families from the real change set (8 paths, including the generated artefacts and tests). All 107 were run, each exit code captured BEFORE any pipe and written to a file, and `--ran FILE` reconciles to **107 derived / 107 run / 0 NOT-MEASURED / 0 UNRUN** — a DERIVED zero, since every line carries its code and none of them is 3. Eight families first returned a non-zero that was NOT a finding and are green after their prerequisite was met: five reported `PREREQUISITE NOT MET` for build state (cleared by a full `pnpm build`), one needed the `MANIFEST` value CI supplies and was then run in CI's exact spelling, and one — `check-plugin-teardown-shape.mjs --self-test` — refused because its fixture commit is unreachable in a shallow clone, cleared by a targeted `git fetch origin 621a487...` rather than an unshallow. ⛔ Not read as clearance: 13 of the roster families run only the checker's own `--self-test`, 49 declare a tracked-file roster whose silence is a fact about a list, 11 declare a population too wide to place, and 6 CI jobs scheduled by these paths run steps no local command covers. Those are CI's. ## Lint and tests Both readings taken at `0af4dfc76`, the final commit. - **`pnpm lint`** = `eslint . --no-inline-config`, run repo-wide rather than narrowed: exit 0, **6853 files inspected** (counted from `--format json`), 0 errors, 0 warnings, 75s. For the record, the config never enables type-aware linting for any file — no `parserOptions.project` and no typed rules — which `eslint.config.mjs:326-336` states with its own positive control, so this diff could not move an untouched file's verdict either way. - **`pnpm --filter @objectstack/spec test`** — 488 files, 14128 tests, all passing. - **`pnpm --filter @objectstack/spec typecheck`** — green, test layer included. - **`check:generated`** — all 15 artefacts up to date. Verify-lock readings, for the lane's baseline: acquisitions waited 0s / 5s / 0s / 4m05s / 1m18s / 0s, and the full `pnpm build` held the lock 4m56s. ## Acceptance notes Observed, in scope of nothing here, and ⛔ not filed by this seat. 1. **The docblock clause "so an extra key here would be dropped there" is measurably false at the pin**, and it is the clause immediately beside the one this PR corrects. `ObjectMapConfigSchema` is a plain `z.object`, not strict, so a `safeParse` of a block carrying an extra key SUCCEEDS; `getMapConfig` uses that result only to decide whether to emit a `console.warn`; and `ObjectMap.tsx:378` returns a spread of the authored block, so the extra key is carried through rather than dropped. An undeclared key inside `map` is therefore neither flagged nor dropped by the renderer — the opposite of what the clause asserts. Left untouched deliberately: the ruling's facts reach the `style` key and objectui's read of it, and they do not reach this mechanism claim, so correcting it would widen the diff past what the ruling authorises. Passed to the seat for filing-class judgement rather than filed. 2. The retraction did **not** need to reach into PR objectstack-ai#18638's hunk regions. Its `view.zod.ts` hunks start at old lines 53 / 2241 / 2771 / 2978 and its `component.zod.ts` hunks at 6 / 738 / 1591 / 1601; this PR's regions are `view.zod.ts:1727-1766` and `component.zod.ts:4, 3163-3175, 3210-3218, 3272-3273`. The `origin/main` merge brought only `content/docs/deployment/validating-metadata.mdx` and `scripts/gen-sdui-manifest-node.mjs`, neither of which overlaps. ## 维护者速读(草稿) ### 改了什么 地图视图配置块 `ListMapConfigSchema` 新增一个可选键 `style` —— 地图底图样式的 URL。同时把组件契约里 `object-map` 的 `map` 属性重新指回这个块(上一个 PR 因为这个键缺失,被迫把它放成了"任意值")。就一个键,没有别名,没有第二种拼写。 ### 为什么改 渲染器一直在读这个键,规格却一直不声明它。后果是:作者在平台的权威声明面上**根本无法声明地图样式** —— 写 `style` 被严格校验拒掉,写 `mapStyle` 规格也不认。代价已经付过一次:为了绕开这个拒收,上一个 PR 把一个组件属性的值契约整个放开成了"任意值",于是那个位置的拼写错误不再有人拦。这次两件事一起收回。 键名取 `style` 而不是 `mapStyle`,因为 Mapbox 与 MapLibre 这个行业本来就叫 `style`,也是渲染器在配置块里实际读的名字。 ### 风险与代价(含回滚) - **接受集只扩大,不缩小**:原先能通过的元数据,现在全部照旧通过。 - **一处收紧**:`object-map` 的 `map` 值从"任意值"变回真正的块契约,所以块内的拼写错误现在会被明确拒收并指名。这正是本次要恢复的保护,但它确实是一个行为变化 —— 如果线上有依赖"任意值"往 `map` 里塞非契约键的写法,会在这里被拒。仓内扫描未发现这类写法。 - **不涉及 objectui 仓**:objectui 那边教人写 `mapStyle` 的开发警告,由另一张 `domain:ui` 卡单独修,本 PR 不碰。 - **回滚**:单次 revert 即可。没有数据迁移,没有退役键,没有 ADR-0087 转换,没有墓碑。 ### 席位意见 ### 你要做的 本 PR 是 `Clause-②: yes`(公开接受集扩大),按流程需要合约层评审后才可落地;评审席位与载体标签不由本席位挂摘。除评审外无需人工动作 —— 本地门禁已全绿,生成产物已按机制重生成。 --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ Co-authored-by: Claude <noreply@anthropic.com>
…w arm rules (objectstack-ai#19046) (objectstack-ai#19095) Fixes objectstack-ai#19046 Clause-②: yes The `object-grid` page-component door declared `pagination: z.unknown()` and `pageSize: z.number()`, so the same authored member carried **two accept sets** and renderers read the looser one. This bounds the page-size members to the accept set the view arm has ruled all along, and deliberately leaves the `pagination` bag open. ## The premise, re-derived by symbol at this branch's base (`362035cc0`) ⛔ No line number inherited from the card — triage warned about exactly that, and the card's own reading was taken on `abb01f1`. | arm | symbol | declaration at my base | accepts `0`? | |:--|:--|:--|:--| | view | `PaginationConfigSchema` (`packages/spec/src/ui/view.zod.ts:867-868`) | `pageSize: z.number().int().positive().default(25)` · `pageSizeOptions: z.array(z.number().int().positive()).optional()` | no | | grid component | `ObjectGridPropsSchema` (`packages/spec/src/ui/component.zod.ts:2632`, `:2634`) | `pagination: z.unknown().optional()` · `pageSize: z.number().optional()` | **yes — both** | The view arm's refusals are pinned **by name** (`view.test.ts` — `should reject negative pageSize`, `should reject zero pageSize`, and the same pair for `pageSizeOptions`). The corpus corroboration also holds at my base — every other page-size declaration in the package is bounded: ``` packages/spec/src/ui/view.zod.ts:867 z.number().int().positive().default(25) packages/spec/src/ui/view.zod.ts:868 z.array(z.number().int().positive()) packages/spec/src/ui/component.zod.ts:2634 z.number() ** the outlier packages/spec/src/marketplace/marketplace.zod.ts:435 z.number().int().min(1).max(100).default(20) packages/spec/src/marketplace/marketplace.zod.ts:456 z.number().int().min(1) packages/spec/src/kernel/metadata-plugin.zod.ts:399 z.number().int().min(1).max(500).default(50) packages/spec/src/kernel/metadata-plugin.zod.ts:429 z.number().int().min(1) ``` PR objectstack-ai#18638, which held this file, is merged (2026-09-18T16:01:37Z) and did **not** tighten it in passing, so triage's downgrade clause does not apply. ## The shape decision — a permissive object, and the evidence that chose it The card's complaint is that the two arms disagree about a page **size**. It is ⛔ not that `pagination` should become a closed shape. Two shapes were plausible; the evidence is one-sided. **Chosen: `z.looseObject({ pageSize, pageSizeOptions })`** — validates the two declared members, passes every other key through. **Rejected: `z.unknown()` plus a refinement judging only `pageSize`.** It looks more conservative and is measurably worse here: - `z.toJSONSchema()` has **no arm for a `custom` check**. A record, the same record with a `.refine()`, and the same record with an aborting `.refine()` all project byte-identically — the mechanism `packages/spec/dropped-refinements.baseline.json` exists to record. A refinement would have left the **published** JSON Schema still accepting `pageSize: 0` while the parser refused it, and it would have needed a **new row in that shrink-only ledger**, which is a ratchet this dev may not raise. - The loose object is a **type** narrowing, so it projects. Measured on the built artifact: ``` packages/spec/json-schema/ui/ObjectGridProps.json pagination.properties.pageSize { "type": "integer", "exclusiveMinimum": 0 } pagination.properties.pageSizeOptions.items { "type": "integer", "exclusiveMinimum": 0 } pagination.additionalProperties {} ** the bag stays OPEN pageSize { "type": "integer", "exclusiveMinimum": 0 } ``` `dropped-refinements.baseline.json` is **untouched** by this PR: `ui/ObjectGridProps` keeps its single pre-existing `filter.element` site and gains none. **Read points, measured at objectui `d18322415`** (the sibling checkout in this container; the `.objectui-sha` pin is `53ded82bf`): `ObjectGrid.tsx:1209` and `:1628` read `(schema.pagination as any)?.pageSize ?? schema.pageSize`, `:4179` reads `schema.pagination?.pageSize`, `:4359` reads `schema.pagination?.pageSizeOptions`. Across objectui's whole source, `pageSize` and `pageSizeOptions` are the **only two members** any `pagination` read point names (37 + 6 reads of `.pageSize`, 7 + 3 of `.pageSizeOptions`, zero of anything else). The objectui registry declares this input `type: 'object'` (`plugin-grid/src/index.tsx:223`). ### What was NOT narrowed, and why - **Sibling keys inside the bag.** `z.looseObject`, not `strictObject`: a sibling key that parsed before still parses **and still survives the parse byte-identically**. Reusing `PaginationConfigSchema` here would have refused every one of them — the `…` in this door's own describe says authors write them — which is a wider breaking change than the card's premise and a different decision. §3 of the new pin is what makes that auditable; §4 records the deliberate asymmetry (the view arm stays closed, this bag stays open), so a future author harmonising the two arms reds a case instead of discovering the consequence in a renderer. - **No `.default(25)` added to the flat shorthand.** The view arm has one; adding one here would change parsed output, not the accept set. - **`pageSizeOptions` WAS bounded, and that is a judgement I am naming rather than burying.** It is the same defect class by a second door: `pageSizeOptions: [0, 25]` puts a zero entry in the page-size selector, which sets the fetch window to zero rows — the card's exact failure. Its shape was already pinned by the view arm (`z.array(z.number().int().positive())`), whose zero/negative refusals are pinned by name, and its read point is measured above. Corpus cost: zero `pageSizeOptions` entries outside the spec's own refusal fixtures are non-positive. ### One second axis, stated rather than left to be discovered `pagination` moves from `z.unknown()` to an object type, so a non-object value (`pagination: true`) is refused where it used to parse. Measured before narrowing: - **zero** non-object `pagination` values on an `object-grid` node in either repository (the `pagination: false` hits in objectui are on `data-table` / `object-data-table`, whose props this schema does not declare, plus one internal per-group table the grid builds itself at `ObjectGrid.tsx:4590`); - the registry has published `type: 'object'` all along, so the html tier already answered `type-mismatch` on one while this schema accepted it — the same shape the `sort` docblock two members up already records; - `ObjectGrid.tsx:4175` reads the key for **presence** (`schema.pagination !== undefined ? true : …`), which means an authored `pagination: false` used to turn paging **ON**. That value now gets a located refusal instead of the opposite of what it says. ## Pins, each with its control New file: `packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts` — 19 cases, 4 sections. | section | asserts | control | |:--|:--|:--| | §1 | `pagination.pageSize` refuses zero / negative / non-integer, and `pageSizeOptions` entries refuse zero / negative — each asserting the issue **code and path** (`too_small` at `pagination.pageSize`), not a bare throw | two LIT CONTROLS: a legal `pageSize` parses and is preserved; the whole ruled bag parses with its options | | §2 | the flat shorthand carries the same accept set, by name | a LIT CONTROL: `pageSize: 25` parses and keeps its value | | §3 | a sibling key in the bag parses with **no `unrecognized_keys` issue**, survives byte-identically (`toStrictEqual`), and a bag of only sibling keys parses | this section IS the control for the trap above | | §4 | both arms refuse the same three non-page-sizes, and both accept `50` | an unknown KEY is refused by the view arm (`unrecognized_keys`) and accepted by the component bag — the asymmetry, pinned | **Defect reproduced in this tree, then the refusal proved able to fail.** Ablation through `scripts/ablation-replace.mjs`, anchor `const GridPageSizeSchema = z.number().int().positive();` replaced by `const GridPageSizeSchema = z.number();` (the pre-PR accept set), from the committed state: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob d9e4dec -> 462c333a1bda Test Files 1 failed (1) Tests 11 failed | 8 passed (19) FAIL §1 ... > should reject zero pageSize AssertionError: expected true to be false ** parse({ pagination: { pageSize: 0 } }) SUCCEEDS ablation-replace: ok restored: blob == HEAD (d9e4dec) and `git diff HEAD` is empty ``` The 11 that reddened are exactly §1/§2/§4's refusals; the 8 that stayed green are the lit controls and §3's openness pins — the right partition, since the ablation removed only the value bound. Restored again through the explicit form: `git checkout HEAD -- packages/spec/src/ui/component.zod.ts`, then `git hash-object` equal to `git rev-parse HEAD:` that path (`d9e4decd6443…`), `git diff HEAD` empty and `git status --porcelain` empty — and the pin re-run green (19/19) from the restored tree. ## Changeset — the derivation, quoting the rule `.changeset/19046-object-grid-page-size-accept-set.md` grades `@objectstack/spec: minor`, carries the BREAKING banner, `Clause-②: yes (narrowing)`, a FROM → TO table and the ADR-0087 disposition. - `scripts/check-changeset-no-major.mjs` header: **"During the launch window we ship breaking changes as `minor`"**, and its end condition — **"at GA … an accept-set narrowing … grades `major`. Until then it is NOT the carrier"** — with `major` refused outright by the guard. So the rule does ⛔ not point at `major`, and there is nothing here for the maintainer floor to rule on. - `pr-automation.yml` "WHICH LEVEL": a widening takes at least `minor`, and the level axis refuses `patch` across the board on a PR that declares clause ②. Declaring `Clause-②: yes` therefore forces at least `minor` — which is where the launch-window rule already put it. - Direction carriers, per the same header: the **BREAKING banner** plus the **ADR-0087 disposition**. Disposition is `registered ui-object-grid-page-size-positive-integer-refused`, a new semantic entry — the four `not-required` categories are all refused by construction here (`unpublished`: spec publishes; `no-migration-prescription` and `runtime-interface-only`: the body carries a FROM → TO table, and "a changeset that ships instructions for rewriting a consumer's code cannot also claim that no consumer has to rewrite anything"; `type-surface-only`: this is a runtime accept set on a metadata surface, not a type annotation). - `skip-changeset` was never available: this moves a published accept set on a package that ships. Verdicts: `check-changeset-no-major.mjs` exit **0**; `check-adr-0087-registration.mjs` exit **0** — `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. ## Verification Full census derived from the real change set after the changeset existed, at `8ecc9b6ed`, with every exit code captured **before** any pipe: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 8 path(s) vs merge base 07c6f82, three-dot; 109 commands 107 exit 0 · 2 PREREQUISITE NOT MET (exit 3) · 0 findings ``` The two that could not run, neither a pass nor a finding: | family | reason | what it needs | |:--|:--|:--| | `pnpm check:dual-build-cjs-loads` | `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/` (34 packages) | a repo-wide `pnpm build`; CI's `Build Core` supplies it | | `pnpm check:type-check-debt` | `--re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk — @objectstack/driver-turso` | `turbo run build --filter='./packages/*' --filter='./packages/*/*'`, as lint.yml does | Four families reported `PREREQUISITE NOT MET` or a missing input on first run and were then **made to run** rather than declared: `check:doc-formula-expressions` and `check:doc-security-posture` (needed `@objectstack/formula` + `@objectstack/lint` built) and `check:skill-examples` (needed `@objectstack/client-react`'s closure) all became exit 0; `check:react-declaration-parity` was run as CI runs it (`MANIFEST="$PWD/sdui.manifest.json" … --strict`) and reports **no new declaration divergence vs the accepted baseline**. Beyond the census: - `pnpm --filter @objectstack/spec test` — **495 files / 14539 tests pass** (post-merge); `typecheck` green, test-layer ledger unmoved at 54 files / 259 errors / 144 pinned signatures. - `pnpm --filter @objectstack/spec check:generated` — **all 16 generated artifacts up to date**. Three were proved stale and regenerated with `--fix` only (`api-surface-declarations/`, `content/docs/references/**`, the strictness-ledger counts); the `authorable-surface.base.json` anchor was never touched. - The one in-repo consumer of the changed surface is `packages/lint` (`ComponentPropsMap`, `@objectstack/spec/ui`): `typecheck` green with its ledger unmoved (2 files / 6 errors / 2 pinned), `test` **104 files / 3910 tests pass**. No corpus fixture anywhere in `examples/`, `apps/` or another package authors `pagination` on an `object-grid` node, so nothing in the tree newly fails to parse. - Repo-wide `pnpm lint` (`eslint . --no-inline-config`) — exit **0**, whole tree, no narrowing claimed. - `pnpm check:nul-bytes` exit 0, plus a direct control-character scan over all 8 changed paths — clean. - Merged `origin/main` through `scripts/pm/os-regen-merge.sh` (its step 2 took main's side of `api-surface-declarations/ui.txt`, which both sides moved, and step 3's hook held the regeneration debt until it was discharged). This branch's delta against `origin/main` on that shard is now **exactly the two `pagination` hunks**, with main's own advance intact. ### The widening-tells reading, with its caveat ``` node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF -> exit 0 ✓ the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already routes to contract review, so a tell on top of it decides nothing. ```⚠️ **That exit 0 is the absence of a reading, not a clean one.** With `yes` the gate short-circuits and examines **no file**. Run as a **diagnostic only** with `--declaration no`, it exits 4 on two T1 tells: `component.zod.ts:2689` (`pageSizeOptions`) and `:2692` (`pageSize`) — *"a new key on a Zod object schema"*. Textually right, semantically inverted for this diff: both members were already writable through `z.unknown()`, which accepted everything; what the diff does is **bound** them. That is a limitation of the matcher, not a signal about this PR, and it is in the acceptance notes below rather than repaired here. ## Acceptance notes *The two paragraphs below were added by the `domain:spec#3` seat after the body's single dev write, on the dev's own hand-over; ⛔ a dev writes a PR body once, at creation.* **The migration registry, with four open PRs adding entries to it.** Mine, objectstack-ai#19090, objectstack-ai#19084 and objectstack-ai#18319 each add one semantic entry. Identity cannot collide silently: the entry id IS the identity and the **filename is a function of it**, so a duplicate would be a loud git add/add conflict — the generator says so in as many words, and the four ids are four distinct files. Order is **derived** `(major, id)` from the directory listing, with no index file and no positional consumer (`migrations/chain.ts` keys by MAJOR, `MIGRATIONS_BY_MAJOR[m]`), so a clean text merge cannot express a wrong *meaning* — the `18.` prefix is the protocol-major bucket, not a sequence number. The gate is `pnpm --filter @objectstack/spec check:migration-registry`, run at exit 0 (「229 semantic, 195 retired-key, 181 retired-def」 current): it proves the emitted regions equal what the entries directory says, so a merge that dropped one side reds and one that kept both out of order reds too. Adjacency measured over the 141 existing `18.*` entries plus the four in flight: **7 / 49 / 61** existing entries lie between mine and objectstack-ai#19090 / objectstack-ai#19084 / objectstack-ai#18319 — no pair is adjacent, and the register's own insertion-only property then predicts a clean, current union whatever the landing order.⚠️ And `registry.ts` is deliberately **NOT** in the `merge=os-regen` register (classified MIXED, 「a deferral would launder the prose」), so a conflict there is **loud and a human's** — the silent-drop class does not reach it. **The hand-written docs negative, recorded so it is not reopened.** Probe: hand-written `content/docs` trees (excluding `references/` and `releases/`) authoring a `pageSize` value this narrowing refuses (`0`, negative, decimal) → **ZERO**. **Lit control, same instrument:** it does find authored `pageSize` occurrences — `content/docs/api/data-api.mdx:42` (`?pageSize=5`) and `content/docs/api/error-catalog.mdx:151` — over 2 hand-written pages and 9 pages including the generated tree, so the zero is a reading rather than a dead grep. **Attribution, which is the part that matters:** neither control hit is this door's `pagination.pageSize` — `data-api.mdx` documents `pageSize` as an *unknown REST query parameter* refused in favour of `top` / `$top` / `limit`, and the remaining pages are the metadata response shape, the object page and the metadata-plugin page. Four different `pageSize` members, none of them this one. ⇒ nothing owed on the hand-written side; the generated `content/docs/references/ui/component.mdx` already moved in this diff. The attribution step is the prescription of **objectstack-ai#19093**, filed today after a name-based hit produced a false stop-the-line alarm on a sibling PR. Observations found in passing. ⛔ None is filed as a card by this PR, and none is in its scope. - **The widening-tells matcher cannot tell a narrowing-inside-a-bag from a widening.** A PR that honestly declares `Clause-②: no (narrowing)` — a legal, precedented declaration (`.changeset/17499-groupbyfield-non-padded.md` carries exactly it) — and bounds a member inside a previously-`z.unknown()` bag is blocked at exit 4 by a T1 tell that names the bound as a widening, because the matcher reads the added key text and not the member's prior schema. Reproduced on this diff, above. The honest declaration is the blocked one. The successor: the next accept-set narrowing on this board. Dedupe words: `widening-tells T1 narrowing inside z.unknown bag`, `check-widening-tells false tell narrowing`, `clause-2 no narrowing blocked exit 4`. - **`frozenColumns: z.number().optional()`** on this same door (`component.zod.ts`) is unbounded, and the renderer reads it as a leading-column count. ⛔ Not filed and ⛔ not touched: no repro, no measured consumer breakage, and it is not this card's member. Noted, not filed. The successor is any future PR on this door's numeric members. - **`pagination: false` / `pagination: true` on `data-table` / `object-data-table`** is authored in objectui and those props are not declared in `ComponentPropsMap` at all, so nothing in this repo judges them. Noted, not filed; that is the sibling repo's declaration surface, not this door's. ## Notes for the reviewer - ⛔ This PR does **not** hang, clear or touch `needs:contract-review`, and writes **no label** — both carriers are the seat's write. `Clause-②: yes` is here because triage ruled it; ⛔ this author does not review its own clause-② verdict. - `packages/spec/api-surface-declarations/ui.txt` moved because the declaration text moved. PR objectstack-ai#19024 removes all 17 of those shards; a deletion-versus-modification conflict there resolves in favour of the deletion and is expected — ⛔ not pre-solved here. - No governed surface is in the diff (checked against `GOVERNED_SURFACES` in `scripts/pm/check-governed-merges.mjs`): `docs/audits/` is not `docs/adr/`. - objectui#9853 is the consumer half's card and objectui#9896 its landed repair; this is the declaration half and was never a prerequisite for it. objectstack#18972 names this same class on the declaration side, and objectstack-ai#19083 landed its `scale` instance three commits before this branch's merge base. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…rces (objectstack-ai#19137) Part of objectstack-ai#18670 — item 2, the **fourth** of the ruling's four named arms: **banned keys**. This body carries no closing keyword for that number on purpose: measured banned-key sites are still unprojected (§6), and whether the card closes is the seat's call rather than this PR's. Clause-②: yes **Carrier:** the published artefacts `packages/spec/json-schema/system/TraceSamplingConfig.json` and `system/TracingConfig.json`. The published JSON Schema **narrows** toward what the runtime already refuses, and no document the runtime accepts becomes refused. ⭐ **The `yes` stands on the ruling's own axis** — a published artefact narrows — and the at-tier review measured that it stands there **independently of the C5 tell**: `check:api-surface` and `check:api-surface-declarations` both exit 0 with **no diff at all**, because `src/shared/refinement-projection.ts` is re-exported by no entry barrel and is not a `.zod.ts`, so it is not in `files[]`. The C5 widening tell is real — the as-const roster `PROJECTABLE_REFINEMENT_PATTERNS` gains `banned-keys` and an exported `bannedKeys()` appears beside it — but that roster is an **internal** `export const`, not the package's public entry surface. ⛔ The `yes` does not depend on it either way. Director ruling batch objectstack-ai#154 item 3, letter **C** (maintainer 「同意」, 2026-09-18T04:56Z): 「the projection emits a refinement only where the rule is a complete, mechanically derivable JSON Schema pattern — banned keys, required-one-of, non-blank — one ledger row at a time; everything else stays annotated as `x-dropped-refinements`」. --- ## ⛔ This body was REPLACED WHOLESALE by the seat, and last refreshed at 2026-09-19T00:07Z for head `184615ded9` The delivering dev writes a PR body once, at creation, and ⛔ does not patch it; a later correction is named in its report for the seat to write. That convention met a case it does not cover: **the tree the first body described no longer exists.** PR objectstack-ai#19084 (`ee5812a5e3`) retired the CEL expression arm at this very slot before this branch merged `origin/main`, so `condition` is now a plain record and not a union — and the union framing ran through §0, §1, §3 and §4 alike. A patch of some sections would have left the artefact self-contradictory about the only tree it can land on, so the seat replaced it rather than appending a third correction block. Five things were stale, and each is now stated for head `384d27ac18`: | # | was | now | |:---|:---|:---| | 1 | the slot framed as a UNION, the ban emitted into `anyOf[0]` | a RECORD; the ban is conjoined onto it directly (§1, §3) | | 2 | 「objectstack-ai#19005 的普查走到 X 就停了」 — an account of a sibling release being wrong | **RETRACTED.** The candidate set is TIME-DEPENDENT; objectstack-ai#19005 read its own tree correctly (§0) | | 3 | the `$`-ban reaches ONE published node | **THREE**, each measured and named (§6) | | 4 | `77 derived / 74 exit 0 / 3 exit 3` | **82 derived / 78 run, all exit 0 / 4 NOT MEASURED** (§7) | | 5 | a live `Clause-②` disagreement between the claim and the ruling | settled at **`yes`** on both carriers, and the claim comment carries the correction | ⛔ Item 4 and item 5 were the **seat's** errors, not the dev's: the dev copied the claim line verbatim as the dual carrier requires, and only the seat writes claims and labels. Item 2 was the dev's, and the dev retracted it itself on measurement. The retracted text is preserved at the end of this body as HISTORY rather than deleted. --- ## 0. The pre-condition the releasing seat set — and the answer The release of objectstack-ai#19005 set a hard gate on whoever took this card next: > Whoever takes it next must **re-derive the banned-keys candidate set FIRST** and, if it is still empty, **return the card rather than dispatching a dev to find nothing.** **Re-derived. The set is NOT empty, and its clean member is the card's own worked instance.** ⭐ **The candidate set is TIME-DEPENDENT, and that is the whole reason the pre-condition was worth setting.** objectstack-ai#19005's census recorded zero clean candidates, and that was a **correct reading of its own tree** — the `dialect` predicate at this slot did not exist yet; it arrived with objectstack-ai#18638, hours later. The instruction to re-derive the set FIRST is exactly what caught a candidate that landed after the last census, and it is the reason this card had work in it at all. ⛔ No sibling release was wrong; an earlier draft of this body said one was, and that claim is withdrawn. **Instrument:** a TypeScript-AST scan of every `.refine` / `.superRefine` / `.check` call expression under `packages/spec/src/**/*.ts` (non-test), dumping each predicate's argument text — **114 custom-check call sites** across 1008 source files (`superRefine` 69, `refine` 44, `check` 1; 3 `.overwrite` calls excluded, they are not custom checks). LIT CONTROL: 6 of those call sites spell an already-declared arm (`requiredOneOf` ×2, `NON_BLANK_STRING` ×3, `dependentRequired` ×1), so the scan does see the population it is supposed to see. **Radius, by form:** source text of tracked files. **A known target outside it:** whether a given call site's node is a *ledger row* — the ledger's sites are computed at run time by the detector against `packages/spec/json-schema/**`, which is gitignored and returns 0 tracked entries. That is precisely why the earlier shape-only reading on this card was recorded as "not a reading". So the population question was answered with the instrument that can see it: `collectDroppedRefinements` run over the live schemas, plus the generator's own census. **Result — 4 of the 114 predicates judge KEYS at all**, and they split three ways: | call site | predicate | verdict | |:---|:---|:---| | `src/system/tracing.zod.ts` (sampling `condition`) | `!('dialect' in value)` | ⭐ **clean candidate** — a static, self-contained, finite key ban. **2 ledger rows.** | | `src/data/filter.zod.ts:1916` | `!Object.keys(condition).some((key) => key.startsWith('$'))` | an **open** key set — not this arm (§6). Detector verdict `undecidable`, **0 ledger rows**, yet **3 published nodes**. | | `src/ui/action.zod.ts:1844` | `Object.keys(hints).every((k) => known.has(k))` | allowed keys computed from the sibling `data.params` — not mechanically derivable; stays dropped and annotated, exactly as the ruling prescribes. | | `src/data/driver/common.zod.ts:537` | credential leaks at named paths | judges **values**, not key names. Not this pattern. | ## 1. The arm `banned-keys` — "no document may carry any of these keys" — emitted as `propertyNames` with a `not` over the banned names. Same closed-vocabulary mechanism the three landed arms use, no second one introduced: `src/shared/refinement-projection.ts` declares the arm and builds the predicate from that declaration, `scripts/lib/refinement-projection.ts` emits it, and both halves still reach `z.toJSONSchema` through the one shared `projectPublishedJsonSchema` call. **The slot is a record, not a union.** objectstack-ai#19084 retired the CEL expression arm of `TraceSamplingConfigSchema.composite[].condition`, so the node is now a single `z.record(z.string(), z.unknown())` carrying the retirement's own refusal hook and its `abort: true` message. The anonymous `.refine((value) => !('dialect' in value))` that guarded it is replaced by the **declared** `bannedKeys(['dialect'])` — the retirement's prescription, error hook and message are taken from `main` whole, and only the predicate is declared. ⛔ The retirement's behaviour is unchanged by this PR; what changes is that the rule now has a published form. **Exact, not approximate.** A JSON object's properties are exactly its own enumerable string-keyed ones, and `propertyNames` judges exactly those names — so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value: a banned key present with a `null` value is present on both sides. ⛔ **The predicate reads OWN properties and never `key in value`.** `in` walks the prototype chain, so a ban on a name `Object.prototype` carries — `toString`, `constructor`, `valueOf` — would refuse `{}` itself while `propertyNames` accepts it (`'toString' in JSON.parse('{}')` is `true`). That is a disagreement about a JSON **document**, not an edge outside the domain, and it is pinned in both directions. The shipped predicate spells `Object.prototype.hasOwnProperty.call(value, key)` for that reason. **The emitted keywords are conjoined, never substituted.** The node is a record and already states `propertyNames: { type: 'string' }` of its own; replacing it would trade a key-TYPE rule for a key-NAME rule, which is a narrowing paid for with a widening. The ban goes under `allOf`, the same discipline `emitNonBlankString` follows for an existing `pattern`, and the measured `format-type.ts` hazard is untouched — a top-level `anyOf` is still never written, and the reference renderer reads neither `allOf` nor `propertyNames`. **An empty key list emits nothing**, and for a stronger reason than "it would ban nothing": `enum` is specified as a non-empty array, so `{ not: { enum: [] } }` is an **invalid** schema rather than a vacuous one — ajv refuses it with "enum must have non-empty array", which would take the whole published file down instead of leaving a keyword nobody reads. The declaring signature takes a non-empty tuple, so the guard is belt-and-braces at a seam two files apart. ## 2. The rows retired, by name `packages/spec/dropped-refinements.baseline.json`, **202 entries / 553 sites → 200 / 551**: | row | before | after | |:---|:---|:---| | `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — drops nothing now | | `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent |⚠️ Both paths are the **post-retirement** spellings. On the tree this PR was first written against they read `…condition.options[0]`, because the node was then a union arm; objectstack-ai#19084 renamed them by making the node a record, and the rows deleted here are the renamed ones. 2 rows deleted, 0 shrunk, **2 sites closed, 0 sites added anywhere**; the ledger diff is deletions only. Generator census after: **551 dropped across 200 published schemas, 357 projected** — 224 `non-blank-string`, 129 `required-one-of`, 2 `dependent-required`, **2 `banned-keys`** — 9 undecidable. The `measured` block is re-snapshotted from this run: `refinementSitesThatDidProject` 367 → **357** and `refinementSitesWithNoJsonFormToCompare` 3 → **9**. ⛔ **This PR moved neither number.** The projected total fell because objectstack-ai#19084 retired expression arms elsewhere in the tree; the main-tip block was already stale on its own tree. Re-snapshotting is what this PR owes for editing the file at all, and it is not a reading this arm produced. ## 3. The card's own worked instance, before and after The issue body cites `system/TraceSamplingConfig.json`: ``` condition.anyOf[0] = {"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}} ``` — "That accepts `{dialect:'cel'}` — which the **runtime refuses**." The union wrapper is gone with objectstack-ai#19084; the same record is now the node itself, and on the merge base it publishes unchanged in substance: ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } ``` After: ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {}, "allOf": [ { "propertyNames": { "not": { "enum": ["dialect"] } } } ] } ``` and `x-dropped-refinements` is gone from both artefacts. Measured at the slot: `{ "dialect": "cel" }` is refused by the runtime and now by the file; `{ "dialect": "cel", "source": "record.amount > 10" }` is refused by **both** sides —⚠️ that is **objectstack-ai#19084's retirement**, not this PR, and this PR neither revives the expression arm nor extends the refusal; `{ "amount": { "$gt": 10 } }` is accepted by both; `{}` and `{ "service": "api" }` are accepted by both; `{ "dialect": null }` is refused by both. ## 4. Blast radius, measured on the whole published tree Re-measured on the **new** base (`aadea24b89`): the three edited source files were reverted to `origin/main`, the generator re-run, and the two trees compared byte for byte. | reading | value | |:---|:---| | per-schema files common to both trees | 1530 | | **byte-identical** | **1528** | | moved | **2** — `system/TraceSamplingConfig.json`, `system/TracingConfig.json` | The diff of each moved file is exactly: **gain** the `allOf` ban, **lose** the matching `x-dropped-refinements` row. Nothing else in either file changes. (The revert leg was proven on disk — each path's blob hash equalled its `origin/main` blob — and the restore leg by `git diff HEAD` printing nothing.) `openapi.json` was measured **separately and by the right instrument this time**: `gen:schema` never writes it, so the first comparison read two missing files and reported a false MOVED. Running `gen:openapi` on both trees gives a byte-identical file, sha256 `34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa` on both sides. ## 5. Ablation — the pins can fail, both halves Re-run on the **new** head; the earlier ablation measured a tree that no longer exists. `scripts/ablation-replace.mjs` replaced the one line dispatching the arm (`emitBannedKeys(jsonSchema, declared.keys);`) in `scripts/lib/refinement-projection.ts`, with the mutation verified against the disk (anchor 1 → 0, blob `0a21fb6f9b66` → `6e55fe06cef5`): | leg | result | |:---|:---| | `refinement-projection.test.ts` | **exit 1** — 12 failed / 46 passed, including the live seam and the ledger-verdict pin | | `gen:schema` | **exit 1** — naming **both renamed rows** (`composite.element.condition`, `sampling.composite.element.condition`), each record/aborting | | restore | blob back to HEAD, `git diff HEAD` empty | The second leg is the one that matters for the ledger's whole purpose: with the emitter gone, the two deleted rows come **back** as undeclared gaps. The row deletion is load-bearing, not decorative. ## 6. What is left, measured rather than estimated `src/data/filter.zod.ts:1916` bans **every key starting with `$`** on a normalized field condition, and it reaches **THREE** published record nodes in `packages/spec/json-schema/data/NormalizedFilter.json`: - `properties.$and.items.anyOf[0]` - `properties.$or.items.anyOf[0]` - `properties.$not.anyOf[0]` Measured on this head: **all three publish as a bare object** with `propertyNames: { type: 'string' }` and **no ban**, none of them appears in that file's `x-dropped-refinements`, and the file **PASSes a document the runtime refuses** — the runtime's answer for that document names the rule: 「a field condition's keys are field names, never `$`-prefixed operators」. All three read **`undecidable`** to the detector, because `FieldOperatorsSchema` carries `z.date()` members that throw in both io directions — so they hold **0 ledger rows** while the branch-pruning path publishes them anyway. ⭐ **Published yet undecidable is a ratchet blind spot in its own right**, and it deserves a line of its own on the card's worklist, separate from the fifth arm it would take to close. Closing the rule itself is a second public-contract decision, not a refactor of this one: an open key set cannot be spelled as a finite `keys:` list — a list that merely sampled the open set would be WIDER than the rule, which the closed list forbids by construction. It needs a pattern-shaped declaration (`propertyNames: { not: { pattern: "^\\$" } }`). ⇒ closing it is a real narrowing with **no ledger row to make it testable**, which is the opposite trade from this arm. ⭐ The changeset now says the same thing. An earlier revision of it claimed these sites 「stay unprojected and **keep their annotation**」, which is false on the tree; the at-tier review caught the disagreement between the two carriers and the clause was corrected before landing. `src/ui/action.zod.ts:1844` stays dropped and annotated, correctly: its allowed key set is computed from the sibling `data.params`, and JSON Schema cannot express "property names drawn from another array field's values". ## 7. Verification Run on head **`184615ded9`**, each exit code captured **before** any pipe. ⭐ **The at-tier contract review returned PASS**, on head `384d27ac18` (record: PR comment `5737573936`). The branch has moved once since, by exactly one prose clause in one changeset file (`git diff --stat 384d27a 184615d` → `1 file changed, 1 insertion(+), 1 deletion(-)`), so the contract surface the review judged is byte-unchanged and `needs:contract-review` is cleared on both carriers (record: `5737671517`).⚠️ **Any count of this suite is only meaningful beside a statement of whether `packages/spec/dist` was built** — the two readings below are both correct, of different trees: | tree | Test Files | Tests | |:---|:---|:---| | **without** `packages/spec/dist` | `496 passed \| 1 skipped (497)` | `14562 passed \| 1 skipped (14563)` | | **with** `packages/spec/dist` built | `497 passed (497)` | `14564 passed (14564)` | The discriminator is `packages/spec/scripts/root-entry-type-nameability.pin.test.ts`, which takes a **dist-freshness branch at collection time** — ⛔ not a platform check and ⛔ not a bare env var. Not fresh ⇒ it registers exactly one test, `it.skipIf(!EXPECT_BUILT_DIST)(…)`, whose NAME carries the freshness state and the rerun command. Fresh ⇒ it registers two (the declaration-emit pin and its canary). `OS_EXPECT_ROOT_NAMEABILITY=1` does not cause the skip; it only turns the skip into a failure for a lane that expects a built dist. ⇒ `14562 + 1 skipped = 14563`, `14562 + 2 = 14564`. | check | result | |:---|:---| | `pnpm --filter @objectstack/spec test` | **0** — see the two readings above; the count depends on whether `dist` was built | | `pnpm --filter @objectstack/spec typecheck` | **0** | | `pnpm --filter @objectstack/spec build` | **0** | | `pnpm --filter @objectstack/spec gen:schema` | **0** — ledger balanced | | `pnpm --filter @objectstack/spec gen:openapi` | **0** — `openapi.json` byte-identical to base | | `pnpm --filter @objectstack/spec check:generated` | **0** — 16/16 generated artefacts current | | derived gate families (`scripts/pm/dispatch-gates.mjs --ran`) | **82 derived / 78 run, ALL exit 0 / 4 NOT MEASURED / 0 UNRUN** | The four NOT MEASURED are `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` — each exits **3** (`PREREQUISITE NOT MET`, a code that is explicitly neither pass nor failure) because each needs a whole-repo build closure that CI's Build Core / lint.yml produces. They are **declared, not skipped**. ⭐ The earlier count of 77/74/3 was taken **before the changeset file entered the change set**; the five families the changeset brings in (`check-empty-changeset` ×2, `release-rehearsal-clone --self-test`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`) all exit 0. Under-reporting a NOT MEASURED as "tested" is the exact inverse of this lane's reading discipline, and the PR body is where a reviewer reads the coverage claim. `packages/spec` has no workspace dependencies, so the dependency-closure build is empty; the public **entry** surface is unchanged (`src/shared/refinement-projection.ts` is not re-exported from `src/shared/index.ts`, which is why `check:api-surface` and `check:api-surface-declarations` both stay green with no artefact regeneration). ## Acceptance notes - **`dropped-refinements.baseline.json` is a shared hot file.** It is a generated, shrink-only ratchet that every holder regenerates, so a collision resolves by **regenerating** (`scripts/pm/os-regen-merge.sh`), ⛔ never by hand-editing conflict markers. This PR did not wait on it. - **F1 was fixed by MERGING, never rebasing.** `origin/main` was merged into the branch (merge `f66984fb1a`); ⛔ no history on this branch was rewritten. - **Noted, not filed — `scripts/build-schemas.ts:830` still carries a stale mention of the retired `api-surface-signatures.json`.** objectstack-ai#19005's release named the next editor of that file as its carrier. This PR does not edit `build-schemas.ts` at all, so it does not become that carrier. Carrier: the next PR that edits `packages/spec/scripts/build-schemas.ts`. - **Noted, not filed — the `build-openapi.ts` branch still has no live sample.** Another seat measured that all nine schemas it projects read `declaredProjectable=0`. This arm's two sites are not among them, and `openapi.json` is byte-identical across this change. Carrier: whoever next teaches an arm a site that OpenAPI publishes. - **Receipt — Docs Drift Check on this head.** The bot derived 5 anchors from 1 changed package and found **no hand-written page naming any of them**; it also declares that `packages/spec/dropped-refinements.baseline.json` yielded **no anchor**, so pages documenting that file are **NOT COVERED by that run** — explicitly not a clean bill of health. Read and carried here rather than left unanswered: the ledger is a machine-maintained ratchet with no hand-written reference page to drift against, and this PR's edit to it is two row deletions plus a re-snapshot of its own `measured` block.⚠️ It also notes its tree was the MERGE of this head into the base, not the head. - The test file's roster pin previously read "names exactly the two arms this change landed" while listing three; it now reads "the arms this list has landed, and nothing else". --- ## HISTORY — what this body used to say, kept rather than deleted ⛔ Three claims were carried by earlier revisions of this body and are **withdrawn**. They are recorded here because a correction that deletes its own subject is not a correction. 1. **「objectstack-ai#19005 的发布说明写错了,那次普查走到 X 就停了」** — WITHDRAWN and refuted on the trees: the `dialect` predicate was introduced by objectstack-ai#18638, **after** both `5e5ec9fa42` (objectstack-ai#18952) and `72c1640504` (objectstack-ai#19005). At those commits the slot carried zero custom checks and no ledger row, so both zeros were correct readings of their own trees. The correct statement is §0's: the candidate set is time-dependent. 2. **`Clause-②: no`** — WITHDRAWN. The claim comment declared `no`, which is wrong on the ruling's own axis: a published artefact narrows. `check-clause2-carriers` separately judged **C5 广化线索** at `src/shared/refinement-projection.ts` (the as-const `PROJECTABLE_REFINEMENT_PATTERNS` roster gaining `banned-keys`), and the precedent is exact: `required-one-of` (objectstack-ai#18952) and `dependent-required` (objectstack-ai#19005) both shipped `yes` for additions to that same array.⚠️ The at-tier review then measured that roster to be an **internal** export that reaches no entry barrel, so the tell did not have to carry the verdict. Both carriers now declare `yes`, and all three carriers — claim, body, changeset — agree. 3. **`77 derived / 74 exit 0 / 3 exit 3`** — WITHDRAWN, superseded by §7's `82 / 78 / 4`. **Attribution (prose, because the edit side of a PR-body write always appends its own footer):** this body was written by the `domain:spec` PM seat in session `session_01AmH9bKvGoLjiY86Q4Z3og2`; the change itself was implemented by the dispatched dev on branch `claude/issue-18670-banned-keys-projection`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…semantics (ADR-0136) (objectstack-ai#18985) Fixes objectstack-ai#17778 Clause-②: no The `domain:spec` half of the maintainer ruling on objectui#8069 (decision batch objectstack-ai#119 item 3, 2026-09-12: 「同意」 to **A**, with **Q2 yes** and **Q3 yes**), **reworked** under decision batch objectstack-ai#160 item 1 on objectstack-ai#19003 (letter **A**, maintainer 「同意」 2026-09-18T11:58Z). The renderer half is objectui#8069 and is not in this PR. ## What this PR is now — a record, one producer fix, and nothing else Ruling A removed the schema change from this PR. Decision batch objectstack-ai#122 item 2 (card objectstack-ai#15811, comment [`5644350409`](objectstack-ai#15811 (comment)), 2026-09-12) had already ruled the evaluated-slot narrowing across **all 36 declaring positions** — its own census names *「field / option / grid-column `visibleWhen` / `readonlyWhen` / `requiredWhen`」* — and **PR objectstack-ai#18638 owns it under one ADR-0087 id and lands first**. Card objectstack-ai#17778 ruled fault semantics, not the carrier symbol, so nothing ruled is lost. **Removed here** (commit `9f30a18a9`): the three `FieldSchema` triad-slot edits; the `PredicateSchema` / `PredicateInputSchema` rebinding, which go back to composing the persistence schemas, wide; the `field-rule-predicate-evaluated-slot-source-required` ADR-0087 entry (the entry file and, through `gen:migration-registry`, its registry rows) and the changeset marker; the triad pin test; the two ADR anchors; and every api-surface / reference-page row that existed only because of those. **Kept, per the same ruling**: ADR-0137 (the fault-semantics record, renumbered — see below), the ADR-0089 pointer addendum, and the producer fix. **Re-derived and removed** (see the measurement below): the ADR-0058 D7 roster entry. ### The revert is byte-exact, not "close enough" The three sources and the `field.zod` anchor were restored with `git checkout d8b12fc --` (the merged-main parent of this branch's merge commit, a pinned sha, not a moving ref); `git diff d8b12fc` is **empty** for each. `registry.ts` was not hand-edited — the entry FILE was deleted and `gen:migration-registry` re-emitted the generated regions; the result is byte-identical to merged main and `check:migration-registry` is green. After regenerating, the whole diff against merged main is **6 files**, and the generated half of it is **4 lines**: | artefact | delta vs merged main | |---|---| | `content/docs/references/**` | **byte-identical** — 0 rows | | `packages/spec/api-surface-declarations/` | **4 lines**, in `root.txt` and `shared.txt`, all of them the producer fix | | `packages/spec/api-surface/`, `export-origins/`, `declaration-map/` | unchanged — `check:api-surface` green | ## The producer fix, on its own terms `cel()` and `expression()` (hence `F` and `P`) always write a non-blank `source`, but were declared as returning `Expression`, whose `source` is optional — a declaration of a shape neither function can produce. The fix is at the PRODUCER (Prime Directive objectstack-ai#12): the return type now states what the helper emits. The docblock was **rewritten** so it no longer rests on the triad requirement this rework removes; what it now says is the general fact plus the live consumer, `FlowEdgeSchema.condition`. ```diff -declare function cel(strings: TemplateStringsArray, ...values: unknown[]): Expression; +declare function cel(strings: TemplateStringsArray, ...values: unknown[]): EvaluatedExpression; -declare function expression(source: string, dialect?: ExpressionDialect, meta?: ExpressionMeta): Expression; +declare function expression(source: string, dialect?: ExpressionDialect, meta?: ExpressionMeta): EvaluatedExpression; ``` Narrowing a return type removes nothing from a caller — `EvaluatedExpression` is assignable to `Expression` — so no call site changes. ## `Clause-②` re-judged, and the changeset level follows it The previous body declared `Clause-②: yes (narrowing)`. With the accept-set narrowing gone that declared something this diff no longer does, and `Check Changeset` read it and reddened. **Re-judged against the six files that remain: `Clause-②: no`.** The judgement is measured, not asserted: | question the declaration asks | reading | instrument | |---|---|---| | does it widen an accept set? | **no** — no zod schema moves at all; `packages/spec/src` changes are two return types and two docblocks | `git diff d8b12fc -- packages/spec/src` | | does it narrow an accept set? | **no** — same reading; the narrowing left with commit `9f30a18a9` | as above | | does it expand the public surface? | **no** — no export added, removed or renamed | `check:api-surface` green, with no removed-or-narrowed report | ⇒ the changeset is **`patch`**, the BREAKING banner is gone and so is the ADR-0087 disposition marker — both belonged to the narrowing that owned them. Something published still moves (two return types in the shipped `.d.ts`), so `skip-changeset` would be wrong; a producer-side fix in a released package is exactly what a `patch` entry is for. Re-run locally against a `pull_request` payload carrying this body, before pushing: `GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event PAYLOAD_PATH` → **exit 0**, `NOT DECLARED — the clause-② declaration reads 'no'`. The same command against the OLD body reproduces the CI red, so the local run is a measurement and not a hopeful one. ## ADR-0136 is renumbered to ADR-0137 PR objectstack-ai#18480 added `docs/adr/0136-declared-journeys-as-priority-anchor.md` about 42 hours earlier. `scripts/check-adr-anchors.mjs` prescribes exactly this — the NEW record takes the next free number, and renumbering an already-accepted record was ruled out, so before it is referenced is the only cheap moment. **`0137` re-verified free on this rework**, not inherited from the earlier sweep: | query | result | |---|---| | `git ls-tree origin/main docs/adr/` | tops out at **0135** — neither 0136 nor 0137 on `main` | | added `docs/adr/*` files across **all 31 open PRs** (`GET /pulls/{n}/files`) | two hits, both `0136`: objectstack-ai#18480 and this PR. **Zero** on 0137 | The scan lit twice on `0136`, so the zero on `0137` is a reading and not a dead query. ## Three corrections the record owed **1. Its Status line claimed an implementation it no longer has.** It now says what is true: this record declares and implements nothing. D1's authoring refusal is batch objectstack-ai#122 item 2's, carried by objectstack-ai#18638; D2 / D3 / D4 are consumer-delivered in objectui#8069. **2. The gate-slot conversion is RULED and IN FLIGHT, not "filed as a follow-up".** The dangling sentence is gone. The record's claim that converting the gate slots "would bake a direction the ruling did not give" is true of batch **objectstack-ai#119** and was silent about batch **objectstack-ai#122 item 2**, which gave exactly that direction six days earlier, and about objectstack-ai#18638 which implements it. The claim is now stated as what it is — a statement about which ruling authorizes what, not a reason the conversion should wait — and the lint-side cost (`validate-visibility-predicates.ts`'s `celRefusal` records the opposite position today) is named as a cost objectstack-ai#18638 carries, not as an objection. **3. The hand enumeration is replaced by a citation of objectstack-ai#15811's census, because the hand list had already rotted.** It omitted `packages/spec/src/system/settings-manifest.zod.ts:424` and `:686`, both `visible: SettingsVisibilityInputSchema`, which is `ExpressionInputSchema.superRefine(...)`. Measured through `SettingsManifestSchema.safeParse` on the built `dist`: | authored `visible` | manifest slot (`:686`) | specifier slot (`:424`) | |---|---|---| | `{ dialect: 'cel', ast: … }` | ACCEPTED | ACCEPTED | | `{ dialect: 'cel', source: ' ' }` | ACCEPTED | ACCEPTED | | bare `' '` | ACCEPTED | ACCEPTED | | **LIT CONTROL** `'data.provider.toUpperCase()'` | **REFUSED** `custom@visible` | **REFUSED** `custom@specifiers.0.visible` | | DARK CONTROL `"data.provider === 'smtp'"` | ACCEPTED | — | The refinement is live at both slots and narrows neither the `ast`-only nor the blank-`source` arm — `if (!source) return;` is the line, and the lit control is what makes the six ACCEPTEDs a reading. ## The ADR-0058 D7 roster entry — re-derived, then removed The ruling's KEEP list names it, and carries NO re-derive clause. The instruction to re-derive came from this seat's dispatch brief, not from the maintainer — recorded here because the earlier wording attributed it to the ruling. The re-derivation concluded the entry no longer belongs (measured: discovery finds 37 positions with the line present and 37 without, floor 37 unchanged and met at 37; the alias types zero slots), so the line is removed here pending the maintainer's explicit confirmation of that removal. The roster lists schemas that **declare** an expression surface — "a slot whose accepted grammar is narrower gets its own schema and must be listed here too". `PredicateInputSchema` earned its place only while the rebinding made it `= EvaluatedExpressionInputSchema` and bound the triad to it from another file. Reverted, it is a plain alias of `ExpressionInputSchema` typing no slot, and the ledger header's limitation 2 names it as the standing **latent** example — leaving it rostered would make that paragraph false. objectstack-ai#18638 measured the same thing independently: *「`PredicateInputSchema` is a plain alias of `ExpressionInputSchema` with zero slot users; it stays wide with the schema it aliases」*. Measured twice on this branch with the revert already applied, by raising the `head` floor to 9999 through `ablation-replace.mjs` so the assertion prints the count: ``` roster entry PRESENT -> discovery found 37 position(s) via 'head' (floor 37) roster entry ABSENT -> discovery found 37 position(s) via 'head' (floor 37) ``` Identical, because the three triad positions are head-matched by `ExpressionInputSchema` again. Both mutations landed and both restores verified on disk (`anchor 1 -> 0`, then `blob == HEAD` and `git diff HEAD` empty). Identity grep agrees: `PredicateInputSchema` has **2** hits under `packages/spec/src/**/*.zod.ts` — its own definition and its `z.input` companion, zero slots — against a lit control of **19** files for `ExpressionInputSchema` and a dark control of **0**. ⛔ **Not a gate weakening.** The `head` floor stays **37** and is met at 37; no ledger row is deleted, no floor is lowered, no test is skipped or quarantined. The same three surfaces are discovered through the schema that types them. Both dogfood files are byte-identical to merged main. The same statement holds for the two other removals: the triad pin test and the ADR-0087 entry are removed because the behaviour they recorded is no longer in this PR — not to turn anything green. `packages/qa/dogfood` re-run after the removal: **7 passed (7)**. ## ⛔ GOVERNED SURFACE — this PR parks as a draft, by design `docs/adr/**` is on the register, so this is the regime's correct resting state, not a stall. An authorized approval is owed before any seat lands this. This seat has not flipped it ready, has not enqueued it and has not armed auto-merge. The `needs:contract-review` carrier is the review seat's and stays hung; a fresh at-tier review is owed on this head. ## Evidence - `pnpm --filter @objectstack/spec check:generated` — proved exactly **2 of 16** artifacts stale (`api-surface-declarations/`, `content/docs/references/**`) and `--fix` regenerated only those two. Re-run after: **16 of 16** up to date. - `check:migration-registry` green with the entry file deleted — the generated regions match `entries/`. - Gate families re-derived on this head with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` and reconciled with `--ran`; results and every non-zero exit are in the report comment on objectstack-ai#17778. -⚠️ The derivation printed a **STALE TREE** warning: this branch is at least 14 commits behind `origin/main` and 10 of the files the families are derived from moved in that range. No `origin/main` merge was taken in this rework, on purpose — this PR rebases after objectstack-ai#18638 lands, and a merge now would move the `merge=os-regen` artefacts for a base that is about to change. So the family list is this tree's, and CI on the merge queue's rebuilt generation is what covers the rest. ## Acceptance notes - **`packages/spec/src/data/field.zod.ts:1513` `expression`** (the formula slot) is an evaluated slot on `ExpressionInputSchema`. Untouched, and no longer this PR's business at all: it is inside objectstack-ai#18638's 36-position census. - **`PredicateSchema` / `PredicateInputSchema` have zero slot users** — measured above. The ruling says they stay as they are and that a later card may retire them as dead symbols on their own measurement. No anchor is left claiming otherwise: the anchor this PR added for `shared/expression.zod.ts` is deleted, and the `field.zod.ts` anchor is back to its pre-PR text. - The ADR-0137 record's `Consumers` line still names `shared/expression.zod.ts` and `data/field.zod.ts`. That is the set of files the DECISION governs, which is unchanged; it is not a claim that this PR edits them for that reason. ## 维护者速读(草稿) > 席位意见一节留空,由席位在 at-tier 评审后定稿为评论。 **改了什么** — 按 batch objectstack-ai#160 item 1 的裁决 A,把这个 PR 里的**协议收窄整段拿掉**:字段三条规则槽位(`visibleWhen` / `readonlyWhen` / `requiredWhen`)回到原样,`Predicate*` 两个别名回到原样(仍然是宽的持久化契约),对应的 ADR-0087 迁移条目、pin 测试、两个 ADR anchor、以及只因它们才产生的 api-surface 与参考文档行,全部删除。留下的是:**ADR-0137**(改号后的 fault 语义记录)、ADR-0089 的指针附录、`cel` / `expression` 的返回类型修复。另外按指示重新推导后,删掉了 ADR-0058 D7 的那一行 roster 条目。 **为什么改** — 同一个收窄早在六天前就被 batch objectstack-ai#122 item 2 裁决过了,覆盖全部 36 个求值槽位(包含本卡的三条字段规则),并且由 PR objectstack-ai#18638 用**一个** ADR-0087 id 承载、先落地。两个 PR 各带一份收窄,就是一次迁移两个 id、两份 CHANGELOG 说法。objectstack-ai#17778 裁决的是 fault 语义,不是承载它的符号,所以记录留下、收窄交出去,**没有任何被裁决过的东西丢失**。 **风险与代价(含回滚)** — 风险很低:协议行为**零变化**(没有任何 zod schema 移动),对外只剩两个函数返回类型收窄,而 `EvaluatedExpression` 可赋值给 `Expression`,所有调用点照常编译。changeset 因此从 `minor` + BREAKING 降为 **`patch`**,`Clause-②` 重判为 **`no`**(三项读数在上表)。代价是本 PR 不再自带任何强制:D1 的编写期拒收要等 objectstack-ai#18638;这一点在记录的 Status 和 Scope boundary 里明写了,不是留给读者去发现。回滚成本极低——本轮全部是删除与还原,恢复即 revert 这四个 commit。 **席位意见** — (留空) **你要做的** — 这个 PR 碰了 `docs/adr/**`,属受管面,停在 draft 等一个授权批准,这是制度的正常终态。需要你看的是三件事:① ADR-0137 现在**只声明不实现**——D1 交给 objectstack-ai#18638、D2/D3/D4 交给 objectui#8069,这个归属你是否认可;② 记录里原来那句「gate 槽位转换会写进裁决没给的方向」已改写为「那只对 batch objectstack-ai#119 成立;batch objectstack-ai#122 item 2 给了这个方向,objectstack-ai#18638 正在做」,这个更正你是否同意;③ D7 roster 那一行**被删**而不是保留——测量是:删与不删,`head` 发现数都是 37(地板 37),且 `PredicateInputSchema` 零槽位使用,所以它不再是「更窄的别名」。裁决原文把它列在「保留」里,**且未附任何重新推导的条款** —— 要求重新推导的是本席派发令,不是维护者(先前措辞把它归给了裁决,此处更正)。推导结论是该条目不再属于花名册(实测:该行在与不在,发现数都是 37,地板 37 未动且 met at 37;该别名零槽位),故此处删除,**等您明确确认这一删除**。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15811
Clause-②: yes
Rework round — the seat's three items, measured on the rework head (⚠️ NOT the current head — see 〈Base catch-up〉 below)
Seat verdict #18638 (comment) (REWORK on a PASSed contract review). Three items, nothing else re-opened.
1. The changeset and the ADR-0087 entry said something this diff makes false
Both claimed the three union-member positions leave their sibling arm untouched. Re-measured here, base
00115a8442vs head, parsing each value AS MOUNTED throughTraceSamplingConfigSchema:RecordAlertProps.visiblez.boolean()true/falseacceptedServiceLevelIndicator.successCriteria{ threshold, operator, percentile? }dialectkeyTraceSamplingConfig.composite[].conditionz.record(z.string(), z.unknown())At the tracing slot, six shapes the base accepted through that arm alone — measured: the base's
ExpressionInputSchemarefuses all six, so the record arm was the only thing admitting them — are refused at head:condition{ dialect: 'cel' }{ dialect: 'js', source: 'x' }{ dialect: 'nope', source: 'x' }{ dialect: 'cel', source: 5 }{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }{ dialect: 'zzz', foo: 1 }Control that HITS: a structured filter carrying no
dialectkey —{},{ service: 'api' },{ attributes: { 'http.route': '/v1/orders' } }— is accepted at base and at head alike. Without it the sixrefuseds would be a schema that refuses everything.⭐ The narrowing is correct and load-bearing (it is what makes the ruled change non-inert at that slot) and is not removed. What changed is the description: the changeset now carries the table and its FROM → TO, and the migration entry's
surfaceandacceptanceCriteriaboth name the wider sweep that slot needs — flag everyconditionobject carrying adialectkey, not only the two spellings. A changeset becomes the CHANGELOG and an ADR-0087 entry becomes the migration ledger; neither may ship a false sentence.2. The published reference page
.refine()has no JSON Schema projection — measured against zod 4.4.3:z.toJSONSchemareturns byte-identical output for the plain record, the refined record and the aborting refined record ({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}). So regenerating alone could never move that TYPE cell, and hand-editing the page is forbidden and would be reverted. The fix is at source: the slot's.describe()now states the rule, andgen:docsrepublishes it.content/docs/references/system/tracing.mdxnow reads:.refine()in the spec, not only this one. Making the page contradict the artifact beside it would be worse. Reported as an out-of-scope finding rather than repaired here.3. The two unpinned message cells — the fix was in the schema, not only in a test
Measured at the slot, on head as it arrived:
{ dialect: 'cel', source: '' }→ one top-levelinvalid_unionwith the bareInvalid input; the published sentence appeared only inside nested arm issues;{ dialect: 'js', source: 'x' }→ refused with the 「needs a non-blanksource」 sentence, which misnames the fault: that value'ssourceis fine, its dialect is not.Root cause, measured: zod 4.4 reports the ONE arm that did not abort, else
invalid_union. The record arm's.refine()was non-aborting, so it was the surviving arm for every expression-shaped refusal here and answered for all of them — and it answered with the other arm's sentence.⇒ The repair is in the schema, not only in a test: the refine becomes aborting, and its message becomes the arm's own rule (module-local, ⛔ not a new published export). Ablation of the accept set: the refused set is identical with and without
abort— both measured over the ten-value corpus above, so this is a message change and not a second narrowing. After it, the slot answers exactly what the other 35 answer, and exactly what the migration entry's own acceptance criteria promise:condition{ dialect: 'cel', source: '' }/' 'invalid_union@ slot,Invalid inputcustomissue @…condition.source, the published sentence{ dialect: 'cel', ast: … }custom@ slot, published sentenceinvalid_union@ slot, published sentence''/' '(bare)invalid_union@ slot, published sentence{ dialect: 'js', source: 'x' }custom@ slot, publishedsourcesentenceinvalid_union@ slot,Invalid input— no longer blamessourcePins, in
packages/spec/src/system/tracing.test.ts: the accept set (six refusals + the accepting control), both blank spellings' published sentence and its exactcode/path, theast-only and bare-string cells, the negative (a non-sourcefault is not answered with thesourcesentence), and the.describe()the reference page renders. Andevaluated-slot-population.test.ts's published-sentence pin now runs all three refused spellings at all 36 positions instead of only theast-only one — 108 cases, all green. That is what would have caught this slot in the first place.Not re-opened
Clause-②is nowyes, re-declared by the seat under ruling A (batch #155 item 3,5725503887, maintainer 「同意」 2026-09-18T05:13Z). The 28-input strict-subtype measurement is NOT overturned — the conclusion drawn from it is: 「a member replaced on a key line is a change to a published contract … the same review a narrowing owes regardless of the tell」. ⇒ a false tell was never the question; a narrowing owes the at-tier review on its own.minor+ BREAKING banner + ADR-0087 disposition stay.printCelAst, the package-internal helper and the 36-position census stay.packages/spec/api-surface/shared.jsonandexport-origins/shared.jsonare still hash-identical to base (git hash-object:cf260910f1…/0429ff67a6…), andgit diff --stat 00115a8442..HEAD -- packages/spec/api-surface packages/spec/export-originsis empty.Gates, re-derived on the rework head (⚠️ two figures superseded — see the note under it)
node scripts/pm/dispatch-gates.mjs --commandson the merged head, every exit code recorded as it ran, reconciled with--ran: 110 derived, 104 run, 6 NOT MEASURED, 0 unrun (--ranexit 0).pnpm --filter @objectstack/spec build && test && typecheckgreen — 486 files / 14016 tests;@objectstack/formula30 files / 871 tests, typecheck green.check:generated: all 15 artifacts up to date after theorigin/mainmerge and the final rebuild.110 derived, 104 run, 6 NOT MEASURED, 0 unrunreconciliation was taken one892c86e271. The head is now34a63b9d583. ⛔ It is not re-derived here — read it as the reading it was.all 15 artifactsis superseded. feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes #18971 registered a new generated-artifact family and the registered count is now 16. Measured: theGATEDarray inpackages/spec/scripts/check-generated.tscarries 16 entries at70407326463d, ate892c86e271and at34a63b9d583alike — with a dark control on a non-existent array name extracting 0 lines — so the15predates all three heads rather than describing any of them. The15is left above verbatim as the historical reading.34a63b9d583:check:generatedexit 0 — all 16 generated artifacts up to date, working tree clean.Base catch-up —
origin/mainmerged, four deferred artifacts regeneratedMerged
origin/mainb14610255101atab00016c221, regenerated in34a63b9d583. Theos-regendriver deferred on four routed both-sides paths —api-surface-declarations/automation.txt,data.txt,ui.txtandcontent/docs/references/ui/component.mdx— and ⭐ a deferral silently keeps ONE side: in the merge commit, main's token reads 0 on all four while the branch's side is intact. Main's side was restored and all four re-derived from the merged tree; on the head each path carries both counts, against a positive control that hits on every blob of every path (⛔ a control that fires on one shard certifies one shard).The two ⛔ MIXED paths are deliberately not routed to the driver and were hand-resolved.
packages/spec/src/migrations/registry.ts: generated regions stripped, the hand-written remainder byte-identical across base, both sides and the merge, line counts exactly additive (17142 + 121 + 74 = 17337), and both sides' migration entries present by id.packages/spec/src/ui/component.zod.ts: additive text merge (3750 + 4 + 45 = 3799), its.superRefine()untouched.Refinement census over non-test
packages/spec/src, counted by occurrence rather than by matching line:.refine(59 base / 60 branch / 59 main / 60 head;.superRefine(80 at all four;.check(4 at all four. ⛔ Nothing deleted, nothing weakened.Non-zero exits, all declared:
check:doc-formula-expressions,check:doc-security-posture,check:docs-transcript-drift,check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) — each refuses an unbuilt workspace closure and says so. NOT MEASURED, not findings;check:skill-examplesexit 1 — the same class in exit-1 clothing (packages/client-react/distholds no.d.ts);check:react-declaration-parityexit 1 — run as CI runs it,MANIFEST="$PWD/sdui.manifest.json" … --strict:111 spec-only divergences, 1 blocks missing from the registry. Control: the identical command at base00115a8442prints the same two numbers, so it is pre-existing and this PR moves neither.One gate went red on this round's own work and is fixed:
check:doc-authoringrefused an internal issue id in customer-facing spec text — the(#15811)this round put in the tracing.describe(). Removed, page regenerated, gate green; the same gate at base is green, so the id was the only offender.node scripts/pm/check-clause2-carriers.mjs --pair 18638— exit 4, and the dual-carrier row C1 is gone: only C5 remains, with the same two false tells (ui/action.zod.ts:833T2,ui/component.zod.ts:1595T1). ⛔ Reported, not acted on; the matcher repair is #18640's.Rework round authored by the
domain:specexecution seat, sessionsession_01LvwGppdonww4zGLWZo5rho.Decision batch #122 item 2 generalised the evaluated-slot rule:
EvaluatedExpressionInputSchemanow composes into every slot an engine evaluates, whileExpressionSchema/ExpressionInputSchemastay the persistence contract (sourceORast) by item 2 of the same ruling. Anast-only envelope and asourcethat is blank after trimming — through the envelope key or the bare-string shorthand — are refused at the door instead of parsing, registering, and faulting at run time.The population was re-derived, not inherited
The census in the card is six days old and
shared/expression.zod.tsmoved after the ruling, so the 36 figure was treated as a premise. Re-derived by identity on this branch's base00115a8442— a negative lookaround on identifier characters, because the bare substring also fires insideCronExpressionInputSchema,TemplateExpressionInputSchemaandEvaluatedExpressionInputSchema, which is the trap that inflated triage's own reading on this card (32 files, five of them Cron-only):ZzzNoSuchSchemaThat 7-versus-17 gap in one file is the trap itself, in miniature. Identical to the measured census (#15811 (comment)), position for position. Two aliases:
ui/action.zod.tsActionConditionInputSchema(mountsvisible+disabled) andsystem/settings-manifest.zod.tsSettingsVisibilityInputSchema(mounts the specifier and manifestvisible). Three positions reach the schema as a union member rather than head-of-declaration.PredicateInputSchemais a plain alias ofExpressionInputSchemawith zero slot users; it stays wide with the schema it aliases.Two defects found while measuring, both fixed here
1. The narrowing was INERT at
TraceSamplingConfig.composite[].condition. That slot isz.union([z.record(z.string(), z.unknown()), …]), and a bare record arm accepts{ dialect: 'cel', ast }as an ordinary record — so swapping the other arm changed nothing. Measured: after the swap and before this fix the slot still answeredsuccess: trueon theast-only envelope, while its 35 siblings answeredfalse. The structured-filter arm now declines an object carrying adialectkey, which is an expression attempt whatever it got wrong. Shipping the swap alone would have been a declared-but-unenforced narrowing.2. Four positions refused with zod's bare
Invalid input. Where the declaration wraps the evaluated schema in a WIDER union — a boolean beside it onaction.visible/action.disabled/RecordAlertProps.visible, a structured object beside it onServiceLevelIndicator.successCriteria— the outer union reportsinvalid_unionat the slot and the inner union's sentence never surfaces.evaluatedExpressionUnionRefusalgives those unions the published sentence. It is deliberately stricter than the inner map it complements: it answers only for a blank string or an object carryingdialect, so a malformed threshold object is not blamed onsource. It lives inshared/evaluated-slot-union.ts, package-internal and absent from both barrels, on theunion-branch-policyconvention: a narrowing PR that grows the published export surface widens on a second axis, soapi-surface/andexport-origins/do not move for it.Item 3 — the printer path is real, and measured
The ruling asked for the lossless direction 「where the dialect has a printer」 before falling back to a structured TODO. Measured rather than assumed:
@marcbachmann/cel-jsshipsserialize, andcel-engine.tsalready uses it for its own scope rewrites. So@objectstack/formulagainsprintCelAst(ast), the inverse of the existingparseCelToAst, and the migration entry prescribes it by name instead of describing a capability nobody can call.Measured round-trip, six sources, each re-evaluated on the same scope:
Lossless about MEANING, not bytes — the printer re-renders from the parse tree, so quote style normalises. Dark controls, all four throwing rather than inventing a source:
{},null,{ type: 'nope' }and a plain string each raiseUnknown AST operation.printCelAstconverts that intonulland additionally requires the printed text to parse back through the platform's own boundedparseCelToAst, so it can never widen what this platform evaluates.Where the printer answers
null, and for every blanksource, the ADR-0087 D3 entryevaluated-expression-slots-source-requiredis the structured TODO — naming the object, the field and the slot, and splitting the judgment by fail policy, because removing a key is safe on the fail-soft half of the population and a silent disclosure on the fail-closed half.Why this is a D3 entry and not a D2 conversion, now that a printer exists. The conversion layer lives in
packages/spec, which is dependency-free by Prime Directive #2 and carries no engine —packages/formula/src/normalize.tsstates the same boundary from the other side. A conversion that had to call the CEL printer could not live where conversions live, and one that guessed without a printer would be the platform inventing a predicate.minor, and the ruling saidmajorItem 3 ordered a 「
majorchangeset」.scripts/check-changeset-no-major.mjsforbids amajormarker during the launch window, because the fixed group versions in lockstep and onemajorpromotes all ~70 packages to a whole-stack major — which is a release act reserved to the maintainer. The guard's own header names the two carriers the convention uses instead, and both are present: the BREAKING banner in the changeset body and the ADR-0087 disposition line. The ruling's substance ships; only the marker differs, and it differs because a repo gate forbids the marker. Flagged rather than chosen silently.Item 4 — the mechanical acceptance surface
#17630 is closed and its widening is live on this base: discovery in
packages/qa/dogfood/test/expression-conformance.test.tsmatches a roster name by identity anywhere on a line, attributes it to thefield:it mounts, and resolves file-local aliases. BothExpressionInputSchemaandEvaluatedExpressionInputSchemaare on that roster, so every one of the 36 positions stays discovered across the swap, the ledger'sfile:Schema.fieldcover keys are unchanged, and theSCAN_CONTROLSfloors (head 37 / inline 3 / alias 2) are unaffected — the swap changes the identifier, never the syntactic shape. No ledger row'sfailPolicymoves: the column records what the EVALUATOR does with a bad expression, and no evaluator changed.Clause-② carrier readings, reported rather than acted on
5725503887), and the seat has since acted. C5 below reads three widening tells against aClause-②: nothat no longer stands: the declaration is nowyes, so the C5 tell no longer gates this PR and the at-tier review does. C1 (the split dual carrier) is also closed — the seat hungneeds:contract-reviewon BOTH card #15811 and this PR at 2026-09-18T09:52:56Z / 09:52:58Z. ⛔ The matcher was NOT touched and no C-class licence card was opened; ruling A refuses both by name. The readings below are kept unedited as the record of what was measured at the time.node scripts/pm/check-clause2-carriers.mjs --pair 18638— exit 4, two rows at the time of writing (re-read on the rework head: C1 has cleared, C5 stands — see the rework section above). ⛔ Neither carrier is touched from here; this is the reading, not a verdict.C1 — the dual carrier is split.
needs:contract-reviewis on card spec: the evaluated-slot rule of #15430 reaches only the flow-node ledger — every otherExpressionInputSchemaslot an engine evaluates (formulaexpression, validation / hook / sharingcondition,visibleWhen…) still accepts anast-only or blank-sourceenvelope #15811 and NOT on this PR. That is the state as found; the seat that owns the gate hangs or clears both sides in one stroke.C5 — three widening tells against
Clause-②: no. One was real and is gone: the new published exportevaluatedExpressionUnionRefusalinapi-surface/shared.json, removed by moving the helper package-internal (above), so the published surface is byte-unchanged by this PR. The remaining two are false, and both for the same reason — the matcher fires on an ADDED LINE that has the shape of a widening, and these two lines were added because an options object was appended to a union that gained no member:ui/action.zod.tsActionConditionInputSchema— read as T2 「a new member of a closed set」. The union has the same two members before and after; what is new on the line is, { error: … }.ui/component.zod.tsRecordAlertProps.visible— read as T1 「a new key on a Zod object schema」.visibleexisted before this PR; the line moved for the same options object.Per the gate's own instruction a false tell is repaired in the matcher (
scripts/pm/check-widening-tells.mjs, with a--self-testcase pinning the shape) or filed as its own card. Repairing ascripts/pm/**matcher is outside this card's surface, so it is filed rather than done here — see the report'sout_of_scope_findings.Tests
packages/spec/src/shared/evaluated-slot-population.test.tsis the new pin, in two halves because either alone is a green that proves nothing:packages/spec/srcstill mounts the persistence schema on a code line, with a lit control (the scan does find the name in the definition file and the barrel), a dark control, and an explicit assertion that the Cron / Template / Evaluated siblings do not leak in as substrings;ExpressionSchema/ExpressionInputSchema/PredicateInputSchemastill ACCEPT both shapes, and a healthy predicate still parses at all 36 (the settings pair gets the predicate its own closed grammar accepts).packages/formula/src/print-cel-ast.test.tspins the printer's two claims, including seven dark-control inputs.Three existing pins were rewritten rather than relaxed — each pinned exactly the arm this PR deletes:
system/settings-manifest.test.ts「anast-only envelope is opaque at this layer」 now pins the refusal, and asserts the settings GRAMMAR message is not the one raised, so the two refusals stay independent;ui/action.test.ts「rejects composition with an AST-only visible loudly (ADR-0078)」 — ADR-0078's promise is unchanged, but the refusal moved from therequiresFeaturelowering to the slot, so it now holds with and without the flag. A second case pins that spec/kernel:requiresFeaturecomposed with a blank-sourcevisiblebuilds a predicate that can never parse — ADR-0078's no-silently-inert guard produces the shape it exists to reject #17631's shape (a blanksourcecomposed into( ) && features.admin == true) can no longer reach the lowering at all;ui/view-form-features-root.test.ts「documented boundary」 now asserts the refusal comes from the evaluated-slot rule and not from the features-root scanner this file is about.Repo census for the migration: zero authored occurrences of either refused spelling outside
packages/spec's own refusal fixtures, acrosspackages/,examples/,content/andskills/, against a lit control that hits. Nothing in this repository needs rewriting.Acceptance notes
PredicateInputSchema(shared/expression.zod.ts) remains a plain value alias ofExpressionInputSchemawith zero slot users. Left wide deliberately — it aliases the persistence contract. Noted, not filed; carrier is the ledger's own limit 2, already written up there.celEngine.evaluateon{ dialect: 'cel', source: '' }answers with the AST-only message rather than an empty-source one. Message accuracy only; the verdict is correct. Unchanged here, still uncarried.Authored by the
domain:specexecution seat, sessionsession_01LvwGppdonww4zGLWZo5rho, under the dispatch claim #15811 (comment).Generated by Claude Code