Repository navigation
feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had - #18319
Conversation
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…r vitest Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 7 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 144 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 cbdc39434111fc2fdbeeb4b9843938ee653edc30 && git checkout cbdc39434111fc2fdbeeb4b9843938ee653edc30
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5e710fb9b5bd973d44f9a5d3466d94ddaf4fa2 1d4c99bcbdf082c937d06157d488d8ac191939b6 && git checkout -B drift-repro 5a5e710fb9b5bd973d44f9a5d3466d94ddaf4fa2 && git merge --no-ff 1d4c99bcbdf082c937d06157d488d8ac191939b6
node scripts/docs-audit/affected-docs.mjs --json 5a5e710fb9b5bd973d44f9a5d3466d94ddaf4fa2
|
This PR is red on purpose, and it is blocked on one maintainer decision — not on an unhandled defectWhat is failing, read from Why. The card's ruling adopts The reachability question that decides whether those 21 matter was answered by a failing probe with a lit control, before the change: So Why nothing was done about them. The dispatch order for this card was explicit: if the 21 turned out to be real, stop and report — ⛔ do not rewrite twenty-one fixture ids on an implementer's own authority, and ⛔ do not weaken the regex. Both prohibitions still hold. Weakening is additionally refused by the ruling itself, which requires the pattern 「declared once and referenced from both sites so the two cannot drift again」; admitting underscores on one side re-opens exactly the drift the card exists to close. What is needed. One decision, and it is the maintainer's because the ruling's own population statement is what turned out to be wrong:
The decision is recorded on the card at #17534 and is with the maintainer now. This PR stays draft with no auto-merge and no enqueue until it is answered; the rest of the lane's queue keeps moving. Not blocked, and already fixed in this PR — a second premise that turned out to be real: every Generated by Claude Code |
…omain manifest ids `manifest.id` now enforces the reverse-domain rule, and 21 fixture declarations under `packages/qa` carried an underscore in their last segment. 20 of them are renamed here, underscore to hyphen, exactly the repair the refusal message itself suggests. The 21st, `com.dogfood.flow_fixture` in `packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts`, is left alone: it is not a single-occurrence declaration. The same literal appears twice more in `packages/spec/src/kernel/manifest.test.ts`, as the sample input for the "repairs a dotted value in place" refusal-message assertions. The rename mandate covers single-occurrence declarations, so that one is reported rather than swept. `test/flow-node.dogfood.test.ts` therefore still refuses at boot. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
`com.dogfood.flow_fixture` was held back in the previous commit because the literal also appears twice in `packages/spec/src/kernel/manifest.test.ts`, so it was not a single-occurrence declaration and the mechanical rename mandate did not obviously cover it. Those two hits are deliberate NEGATIVE TEST INPUT, not references to this fixture: line 643 asserts the refusal for that value suggests `com.dogfood.flow-fixture`, and line 653 feeds it alongside `blank`, `my_app` and `support_desk` as a batch of ids the schema must refuse. They are left exactly as they are — renaming them would delete the test of the rule this card adds. The new id is the one the refusal itself prescribes for this value, the same underscore-to-hyphen repair the other 20 took. `manifest.test.ts` stays green (66 passed) with the fixture renamed, which is what shows the two are independent. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Status refresh — the blocker changed, and my earlier comment on this PR is now the wrong description of itMy comment Measured at head The fact that decides how serious this is: of the 63 from the first pass, 62 are in a Current CI, head Why nothing further is being pushed. Renaming ~46 more fixtures across seven additional packages is a scope decision, not a repair — and an hour ago I wrongly told the implementer to fold two The full measurement and the question now with the maintainer are on the card: #17534 (comment) This PR stays draft, with no auto-merge and no enqueue, until that is answered. The 21 ruled renames stay as they are — they are correct and they are landed on this branch. Generated by Claude Code |
…pellings
The maintainer ruled that the fixtures outside `packages/qa` are renamed in
this PR too. Each new id is the one the schema's own refusal prescribes for
the old value, so the tree and the diagnostic agree.
Renamed (18 ids, 16 files): `packages/cli` 14, `packages/lint` 3,
`packages/metadata` 1, `packages/plugins/plugin-dev` 3 (see below), plus the
docblock noted last.
Three of these were NOT single-literal swaps, and are called out because a
naive rename would have silently deleted what the test pins:
* `dev-i18n-packages-reader.test.ts` builds a dependency CYCLE between two
packages, and the `dependencies` keys address the ids BY NAME. The ids and
both dependency keys are renamed together; renaming only the ids would
have dissolved the cycle and left the test asserting nothing. It still
reports `Circular dependency detected`.
* `metadata-type-schema-gate.test.ts` and `format-zod-union.test.ts` and
`authoring-rule-command-parity.test.ts` each carry a `namespace` that
repeats the old id, and the first also carries a route path
`/api/v1/apps/gate_probe/things`. Only `manifest.id` is renamed —
`namespace` has its own rule and the path pairs with it.
* `plugin-dev/src/index.ts` is a DOCBLOCK example, not a fixture: prose
teaching `manifest: { id: 'my-app' }`, an id its own schema now refuses.
It cannot red a test; it is corrected because it is published teaching.
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
…ges/qa
19 more ids, each the target the schema's own refusal prescribes for the old
value, rewritten at the exact offset of the `manifest.id` literal so that a
`namespace`, a `name` or a route path repeating the old spelling is left alone.
Scope of THIS commit is deliberately the single-occurrence ones. Every id here
occurs exactly once in its own file, so the rename cannot silently unhook an
assertion that addresses the old value.
Not included, and reported rather than guessed:
* 15 ids whose literal occurs MORE than once in its own file, so renaming
the declaration alone would leave assertions naming the old value —
`pkg-a` (7 occurrences), `test` (6), `a` (6), `my-app` (5), `demo` (3) and
ten more. These need a coordinated rename of the id and every reference,
which is reading work per file, not a mechanical swap.
* `com.test.14397` — the schema offers NO suggestion for it, because the
failing segment starts with a digit and no mechanical repair exists.
* `artifact-granted-permissions.test.ts` — not a rename at all: it pins the
ADR-0130 D4 door ORDER, that `''` passes the schema and is caught one door
later. The new rule moves the refusal to door 1, so no conforming id can
preserve what the test pins.
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
The last 16, each read before it was touched. Every replacement is anchored on
the `id:` key, so a `name`, a `namespace` or a field named the same is left
alone, and each file's occurrence count was asserted before and after.
Reading first is what made these safe, and two would have gone wrong without it:
* `artifact-collections.test.ts` has `'a'` six times, but only two are the
package id — the other four are a FIELD named `a`
(`{ a: { name: 'a', type: 'text' } }`). A whole-file swap would have
renamed a field the assertions address.
* `package-registry.test.ts` and `lint-protocol-range.test.ts` repeat the old
id as a `namespace`, and `serve-host-config.test.ts`,
`metadata-collection.test.ts` and others repeat it as a free-form `name`.
`namespace` has its own rule and `name` has none; neither is renamed.
Where the repeat WAS a real reference it moved with the id: all seven `pkg-a`
in `packages-capability-gate.test.ts` (package record ids and manifest ids in
the same mock), both in `packages-uninstall-envelope.test.ts`, both `test-app`
registrations in objectql, and both `locale-producer-app` sites in runtime.
`com.test.14397` becomes `com.test.card-14397`: the schema offers no suggestion
for it, because the last segment starts with a digit, so the name was ruled
rather than derived. `ManifestSchema` confirms the replacement parses.
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
…nifest-id-reverse-domain-regex
…tree The os-regen merge sequence's collection commit. `api-surface/kernel.json` and `export-origins/kernel.json` were edited on BOTH sides, so the merge driver ran, exited 0 and silently kept one side; step 2 took main's side and this commit carries the regeneration from the merged tree, built first so api-surface reads a dist that matches src rather than reporting phantom removals. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
|
Enqueued by the director seat ( Generated by Claude Code |
…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>
…e the 27 signature hashes (objectstack-ai#18971) Fixes objectstack-ai#16045 Clause-②: yes (widening) Ruled at `5560224701` (director batch objectstack-ai#60, 2026-09-06, maintainer verbatim 「同意」), re-affirmed by triage at `5724532096`: option A, a readable declaration-text snapshot, ⛔ not a hash. The card body's three mutually exclusive routes predate that ruling and were not re-litigated here. `@objectstack/spec` pinned its public surface on one axis. `api-surface/` records each export as `name (kind)`, and a signature change, a renamed interface field and a dropped union member move **none** of those rows. The only shape pin was `api-surface-signatures.json`: 27 rows, and reference-level even there. This adds `api-surface-declarations/`, the declaration text the packed build actually emits for every export of every published entry point, and retires the 27 hashes it subsumes. ## The counts, re-derived on this head before the first generation The ruling asks for this by name; the card's own numbers were self-declared unverified and 12 days old. | Number | Card | This head (`b33898f5d`) | Unit, and what would make it something else | |---|---|---|---| | entry points | 17 | **17** | type entry points in the `exports` map — those whose `require.types` ends in `.d.ts`. Adding or removing one such subpath. | | `exports` map entries | (not stated) | **19** | every key in the map. The extra two are `./openapi.json` and `./package.json` — asset subpaths with no declaration at all, filtered out by the same `.d.ts` test `build-api-surface.ts` has always applied. ⇒ premise 1 resolved: **17 is right and the map did not grow**; 19 counts two things that were never entry points. | | pinned rows | 5309 | **5336** | `name (kind)` rows summed over the 17 `api-surface/` shards. +27 since the card. Ratio unmoved: 27/5336 = 0.51%, so the headline 99.5% stands. | | distinct exported names | (not stated) | **5200** | (entry, name) pairs. The gap to 5336 is dual-declared names, which are two rows by design. | | signature hashes | 27 | **27** | top-level keys of `api-surface-signatures.json`. Bright control: the first value really is a `sha256:` string, so this counts signature entries and not empty objects. | Premise 3 also holds: all 17 packed `.d.ts` files exist and resolve through the map (3,215,437 bytes for the root entry down to 13,081 for `./integration`). No entry point lacks a packed declaration, so the gap the dispatch reserved for itself did not open. ## What the artefact costs — premise 4, which nobody had costed | | | |---|---| | shards | 17, one per entry point | | declaration blocks | 5336 | | bytes | **12,661,943 (12.08 MiB)** | | lines | **237,706** | | gzipped | **1,071,825 (1.02 MiB)** — against this package's ~17.57 MiB compressed `dist`, so about **+5.8%** of tarball | | largest shard | `system.txt`, 3,592,701 bytes / 73,283 lines | | median declaration | **81 bytes** | | skew | the 20 largest declarations hold **~65%** of all bytes; four exceed 20,000 lines each (`EnvironmentArtifactSchema` 21,868, `ObjectStackDefinitionSchema` and `ObjectStackSchema` 21,851, `ChangeSetSchema` 20,395) | Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed `.d.ts` is a tsup dts rollup, so a Zod schema's declaration is its **fully expanded** structural type. That expansion is exactly what makes an inner field rename visible — and it is also why a single schema can produce a 21,000-line diff. The ruling's stated reason for choosing text over a hash is that the contract-review seat reads the diff; that reasoning holds per declaration and is worth a second look at the top twenty. One reading, for whoever wants it: 31% of declarations hold 97.7% of the bytes, so nothing cheap is available by trimming the tail. ## Both instruments, measured on one tree at one commit The card's thesis is that the old pin cannot fail on a shape change. Not argued — ablated, with the mutation proven on disk by blob hash and the mutation proven to have reached `dist/` before any verdict was read. **A. the source-level control — a renamed interface field, the card's own class.** `JobRunOutcome.reason?` renamed to `degradationReason?` in `packages/spec/src/contracts/job-service.ts` (blob `363443e2` to `d4b1520c`), spec rebuilt, `ablation-dist-preflight` exit 0 confirming the marker reached the built artefact: ``` check:api-surface exit=0 "public API surface unchanged" [BLIND] check:api-surface-declarations exit=1 "~ JobRunOutcome (interface)" [SEES IT] ``` Restore leg: blob back to `363443e2`, rebuilt, `ablation-dist-preflight --absent` exit 0 (marker gone from all 214 built files), `git diff HEAD` clean, gate back to exit 0. **B. the gate can fail on its own artefact.** One field renamed inside `qa.txt` by hand (blob `3f5efb04` to `5b86fec2`, injected occurrences 1, deleted text 0): exit **1**, attributed to `TestSuiteSchema (const)`, failure text naming the regenerate command. Restored to the HEAD blob, `git diff HEAD` empty: exit **0**. ## The retirement, and the coverage proof the ruling demands All **27** signature names resolve to a declaration block in `api-surface-declarations/root.txt`, **0 missing** — enumerated from `defineAction` through `defineWebhook`, each as `(function)`. One honest qualification, because the subsumption is not uniform. For those 27 factory declarations the text is `declare function defineAction(config: z.input of ActionSchema): ActionParsed;` — a type **reference**, exactly as blind to an inner-key narrowing as `typeToString` was. What is gained is not sharper text on the 27; it is the **5309 other declarations**, including `ActionSchema` itself, whose own expanded block is where such a narrowing shows up. So the retirement is a strict superset of pinned declarations, not an equal trade. Nothing published read the retired file — it was never in this package's `files[]`. ## Where it lands, and why there - Generator: `packages/spec/scripts/build-api-surface-declarations.ts`, beside the eight sibling artefact generators, reading the same input through the same `collectEntries` logic. The ruling says "one generator script under `scripts/`"; this reads that as the directory the whole family lives in, because the artefact reads the **built dist** and only the lane that builds spec can run its gate. - Artefact: `packages/spec/api-surface-declarations/ENTRY.txt`, a sibling **directory** of `api-surface/`. Not inside it: `listShardNames` throws on any file in that directory that is not a `NAME.json` shard, so `api-surface/` is closed by construction. No existing `api-surface/*.json` is regenerated by this PR (`check:api-surface` green throughout), which keeps it clear of PR objectstack-ai#18688 and PR objectstack-ai#18319. - Gate: `check:api-surface-declarations`, a step in lint.yml's `Type Check · consumer gates` lane after the two build steps, with `check:api-surface` and the other dist-reading gates. **No new required context** — a step in an existing lane. Registered in the `check:generated` ledger, in `REGEN_ARTIFACTS`, and in `.gitattributes` as `merge=os-regen`. - Sharded per entry point from day one, for the reason its neighbour is: the merge queue rebuilds server-side where no custom driver runs, so two PRs sharing one generated file evict the second. Pit 1 from `5715457322` is answered by the layout rather than by an assumption — and `check:merge-driver`, which reconciles `.gitattributes` against `REGEN_ARTIFACTS` in both directions, is green over the swap. - Published, with the reason the gate demands. `check:published-files` refuses a `files[]` entry that carries none; the registered line says what a consumer does with it — read two published tarballs and see *which declared shape* moved between releases, the question `api-surface` cannot answer. If 1.02 MiB of tarball is judged too much, one line of `files[]` removes it without touching anything else. Three registries had to learn about the new gate, each because it discovered the gate on its own rather than because a list named it: - `check:published-files` — demanded the reason above. - `scripts/pm/dispatch-gates.mjs` — its live manifest edge gave the new gate a population before anything listed it, which is the eighth member of a class whose seventh was recorded the same way. Declared as `CLASS_EIGHTH`, with a case asserting the edge really reaches it. - `scripts/pm/check-widening-tells.mjs` — `PUBLISHED_SURFACES` is derived from `REGEN_ARTIFACTS`, so retiring the signatures row dropped it off that surface and reddened two self-test cases. Both are retargeted to state the retirement as a counterfactual (the surface follows the table, not a literal); ⛔ the new artefact is **not** added to that surface, because the ruling assigns "is a snapshot diff a Clause-② signal" to the skills seat by name and out of this card's scope. Both directions are now pinned, so the boundary is declared rather than forgotten. 483 cases pass, up from 481. ## Verification - **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the merge base, 120 commands, every exit code redirected to a file and read back. **All 120 green.** Four returned exit **3** PREREQUISITE NOT MET on first pass (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`); each names a build, each was built and re-run green, and none is recorded as a finding. Reconciled with `--ran`. - **Tests**: `@objectstack/spec` local project **488 files / 14,182 tests passed**; the tooling suites that name the edited scripts, both projects, **10 files / 220 tests passed** (`sharded-artifacts`, `check-generated-ledger`, `dist-freshness`, `dist-freshness-adoption`, `api-surface-dual-kind-rows.pin`, `build-schemas-check-mode`, `def-key-collisions`, `root-index`, `export-list`, `docs-import-surface`). `pnpm --filter @objectstack/spec typecheck` green. - **eslint, the union rather than a narrowing**: `eslint . --no-inline-config --format json` at `b33898f5d` examined **6856 files**, **0 errors, 0 warnings**, exit 0. The population is eslint's own config resolution and the count is read from its JSON output; type-aware linting is not enabled in `eslint.config.mjs` (no `parserOptions.project`, no typed rules), so this diff cannot move an untouched file's verdict either way. - **Control bytes**: `check:nul-bytes` green over 8906 files, plus a direct scan of all 31 changed paths for the wider control-byte class — no matches. - `scripts/check-single-claim-paths.mjs` in the diffstat is **not mine**: it arrived with the one-commit `origin/main` merge (`16cb493d5`) this PR carries. ## Acceptance notes - `.claude/skills/spec-property-retirement/SKILL.md` line 124 lists `api-surface-signatures` as an instance of a retirement shape, and that row goes stale with this landing. ⛔ Left untouched on purpose: `.claude/**` is a governed surface, so editing it would make this whole PR maintainer-landed for a one-word prose nit. Noted, not filed. - `packages/spec/scripts/build-schemas.ts` line 830 carries the same stale mention. Left untouched because PR objectstack-ai#18952 holds that file; noted, not filed, with the later lander as the natural carrier. - Three files in this diff are held by open PRs and were edited anyway because the retirement forces it, not by choice: `scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948), `scripts/pm/dispatch-gates.mjs` (PR objectstack-ai#18903) and `.github/workflows/lint.yml` (PRs objectstack-ai#18946, objectstack-ai#18889, objectstack-ai#18414). All are hand-written files where a text conflict is visible rather than silent, and all three of my hunks are small and far from theirs. Whoever lands second resolves. - The top-20 skew above is a reading, not a finding: no gate is wrong and nothing is unenforced. It is recorded here because the ruling's own justification for text over hash is per-declaration readability, and at 21,000 lines a declaration that argument thins out. ## 维护者速读(草稿) **改了什么。** `@objectstack/spec` 从今天起为它的**每一个**公开导出留一份"形状快照" —— 不是哈希,而是打包后 `.d.ts` 里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI 闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。 **为什么改。** 原来的 pin 只记"某个名字还在不在",5336 行里只有 27 行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 —— 全部一路绿灯。本次 PR 里有实测:改了 `JobRunOutcome` 的一个字段名之后,旧闸门 `check:api-surface` 退出码 **0**(看不见),新闸门退出码 **1**(点名了那个 interface)。路线是 2026-09-06 决策批次 objectstack-ai#60 里您逐字「同意」的那一条。 **风险与代价(含回滚)。** 代价是体积:12.08 MiB 文本、23.7 万行,压缩后 1.02 MiB,相当于 npm 包增长约 5.8%。更值得注意的是分布极不均匀 —— 最大的 4 个 schema 各自超过 2 万行声明文本,一旦它们变动,复核席位面对的是一份 2 万行的 diff;而裁决选"文本不选哈希"的理由恰恰是"diff 可读"。这一点我按实测如实报告,未自行改动路线。回滚成本很低:从 `files[]` 去掉一行即可停止随包发布;整道闸门回滚就是撤销本 PR,不留任何数据迁移。 **席位意见。** **你要做的。** 只有一件事需要您判断:12 MiB / 23.7 万行这个量级,以及最大 4 个 schema 的 diff 可读性,是否仍符合当初选 A 方案时的预期。若认为需要收窄,那是裁决层面的一次增补,不是本 PR 的返工。其余部分已按裁决落地并自证。 --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ai#18012) (objectstack-ai#19066) Fixes objectstack-ai#18012 Clause-②: yes Ruling executed: decision batch objectstack-ai#146 item 5, **letter A** — maintainer 「146 同意」 2026-09-17T13:16Z. Carrier: the changeset `.changeset/18012-between-blank-endpoint-refused.md` — `@objectstack/spec` **minor**, body carrying **BREAKING for authored metadata**, ADR-0087 disposition `registered filter-between-blank-endpoint-refused`. ## What changed `$between` now requires two endpoints that are **present and non-empty**. A blank bound at either side is refused at the authoring door, and the refusal **names the blank side** — `MIN` / `MAX` plus the index — because the only measured producer pads a half-typed pair, so both bounds are present and the author is the one person who cannot see which one is empty. ``` FROM FieldOperatorsSchema.safeParse({ $between: [1, ''] }) -> { success: true } TO FieldOperatorsSchema.safeParse({ $between: [1, ''] }) -> { success: false, issues: [{ code: 'custom', path: ['$between', 1], message: 'A blank value is not a valid $between endpoint at index 1 (the MAX bound). …' }] } ``` Three spellings, one rule, but only one of them changes what parses: | spelling | before | after | | --- | --- | --- | | `''` | parsed green | refused — **the only behavioural change**; `''` is a string and the endpoint union accepted it | | `undefined` | refused with zod's bare `Invalid input` | refused with the pointed sentence naming the side | | `null` | refused with the 2026-08-31 ruling's own message | **unchanged** — it prescribes the null predicate, a different remedy for a different intent | The refinement rides the endpoint factory `RangeOperatorSchema` (the documentation copy) and `FieldOperatorsSchema` (the enforced copy) already share, so the two cannot drift. The published endpoint description gained the rule in the same edit — declared = enforced — which is the whole of the regenerated `content/docs/references/data/filter.mdx` diff (5 rows, one per carrier). The empty-string arm is an element-level `superRefine`, deliberately not the tuple-level refinement the factory's docblock rules out: a tuple check does not run once an element has failed, whereas the element check runs exactly when the union accepted the endpoint, which is precisely when there is an `''` to report. ## The ADR-0087 half the ruling left to measurement The ruling asked for a D2 conversion entry and explicitly did not pick the behaviour: 「the dev measures which the load path already does for a refused operator and follows that precedent」 (drop the operator, or refuse at load). **Measured, on `origin/main` before the change: the load path does neither.** `applyConversionsToStoredItem` — the one primitive every stored-row rehydration seam calls — never throws and never validates; it replays only the positively-recognised lossless transforms in the conversion registry. A stored view carrying `{ close_date: { $between: ['2026-01-01', ''] } }` comes back as the **same object reference**. No conversion in the registry drops a filter **operator** either: the three filter-adjacent entries are two key strips and a key rename. So the precedent to follow is the one the two nearest narrowings of this same surface already set — `filter-preset-ordering-comparand-refused` and `analytics-date-range-array-two-bounds-required`, both of which decline a D2 conversion because rewriting would be the platform guessing which bound was meant. **Registered as an ADR-0087 D3 semantic entry, with no D2 conversion and no stored-metadata rewrite.** Dropping the operator would be worse than guessing: it deletes a constraint the author wrote and silently **widens** the result set — the failure mode `$nin` carries in the same file. Consequence, stated rather than left to be discovered: the read path does not re-validate stored rows, so **no stored document becomes unreadable**. What changes is that re-saving one is refused, at the endpoint's own path, with the blank side named. ## `migrations/registry.ts` The ruling's Execution line sequenced this on `registry.ts` after objectstack-ai#18319 / objectstack-ai#18420. The dispatching seat measured that this no longer applies and said so on the card: the file's three tables are generated regions fed one-file-per-entry from `entries/`, and all four PRs said to hold it each add their own entry file. This PR did the same — **one new file under `entries/semantic/`, then `gen:migration-registry`**. Nothing was typed between the markers; the `registry.ts` diff is 86 lines of regenerated output and `check:migration-registry` proves the regeneration faithful. ## Verification Run on `e849c873cd` (the merge of `origin/main` into this branch), heavy runs serialized through the shared verify lock. - `pnpm --filter @objectstack/spec test` — **491 files / 14309 tests passed**. - `pnpm --filter @objectstack/spec typecheck` — clean (`tsc --noEmit` + scripts + test-layer ledger). - `pnpm --filter @objectstack/spec check:generated` — **all 16 generated artifacts up to date** after the merge. Exactly one was proved stale during the change (`content/docs/references/**`) and regenerated with `--fix`, which touched only it. - `pnpm lint` — repo-wide, exit 0. - Targeted gates, all exit 0: `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check:nul-bytes`, `check:where-matcher`, `check:query-options-erasure`, `check:test-source-alias`, `check:spec-parsed-alias`, `check:cross-package-test-inputs`, `check:merge-driver`, `check:published-files`, `check:objectui-changeset`, `check:type-check-coverage`, `check:doc-anchors`, `check:docs-single-h1`, `check:docs-spec-enumerations`, `check:quick-reference-counts`, `check-doc-frontmatter`, `check-docs-section-name`, `check-closing-keyword-parity`. - `check:type-check-debt` — **NOT MEASURED**, exit 3 `PREREQUISITE NOT MET`: it needs the whole workspace dist closure built, which `lint.yml` does before the step and this run did not. Not a pass and not a finding. This diff adds no package and moves no ledger entry. ### Reverse verification — the new assertions are not vacuous Ablated through `scripts/ablation-replace.mjs`, which proves the mutation reached disk before the command runs (no `-i` family): ``` anchor "if (endpoint !== '') return;" x1 -> x0 blob d17f958 -> 4450ff1fac67 (the mutation landed) result 5 failed | 162 passed restore blob == HEAD d17f958, `git diff HEAD` empty ``` Predeclared direction: **red**, and exactly the five empty-string cases went red. The `undefined` case, the `null` case and all 162 pre-existing assertions stayed green — which is what separates "this rule is enforced" from "this file's tests pass". No build step is involved: the spec suite resolves `./filter.zod` from source, not from `dist`. ### Fixture sweep Every `$between` array literal in the tree was read for a blank or absent bound: **3 distinct sites, none of them parsing through this schema** — the driver-sql undefined-comparand refusal pin, the service-analytics filter-normalizer pin, and the `parseFilterAST` pin in `filter-comparand-shape.test.ts`. No fixture had to be rewritten. Instrument radius: tracked files this repo's `git grep` matches for `$between`, scanned for array literals; outside it lie the sibling `../objectui` checkout (a different repo, and its half is its own card) and any range built programmatically rather than written as a literal. ## Acceptance notes - **The runtime door is untouched, and it now disagrees with the schema door about `''`.** `parseFilterAST` still reads an empty string as a value — pinned on purpose in `filter-comparand-shape.test.ts` ("refuses ONLY null — falsy and empty-ish members are values, not absence"), and that file is outside this card's file surface and outside the ruling, which scoped the spec half to the schema refinement. Flagged, not filed: the two doors serve two different populations (an author saving a document vs a caller handing a where-clause to the engine) and aligning them is a decision of the same class as this card's, not a seat call. Carrier if it is ever wanted: the same file that carries the null and ordering runtime twins. - **Whitespace-only endpoints still parse.** `{ $between: [' ', 'M'] }` is green, and there is a positive assertion pinning that, so a later reader cannot widen the refusal without noticing they are doing it. The ruling enumerated `''`, `null`, `undefined`; narrowing a published face past what was ruled is the seat call this card's whole history refuses to make. Noted, not filed. - **`FilterConditionSchema` judges no comparand at all** — it is `z.record(z.string(), z.unknown())` at every field position, so it also lets the already-ruled `{ $field }` endpoint through. Standing shape, not a hole this narrowing opened; a test now pins it with that `{ $field }` control beside it so the green reads as a measurement rather than an oversight. Noted, not filed. - **The `Clause-②: yes` line is copied from the dispatch's claim comment, as the ruling set it.** For the record, this diff carries no widening tell: no key, enum member, union arm, export row or registry registration is added, and `check:api-surface` is green with no export delta. Read strictly against the clause's own question (「本卡放宽接受集或扩大公开面吗」) the direction is narrowing-only; the direction is carried in prose and by the changeset's `BREAKING for authored metadata` banner rather than by rewriting the ruling's word. - The objectui half — the builder stops padding a half-typed pair — is objectstack-ai/objectui#9695 and is untouched here. It is safe on its own and may land either side of this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- 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>
…ease pair it really spans (objectstack-ai#19115) Fixes objectstack-ai#18978 Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`) and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped; the schema still ACCEPTS a record without it. Contract-review tier. `spec-changes.json`'s `aggregate.added` / `aggregate.removed` are filled by a release-time api-surface diff of the artifact being published against the previously **published** one, so they span **one release** — under a record keyed by protocol major (`from: 10, to: 17`), with every entry carrying only `since: 17` / `removedIn: 17` and `perMajor[16 → 17].added` sitting at `0` beside it. Nothing in the file distinguished one minor's slice from the whole major-boundary delta. --- ## 1 · The defect, re-measured on a real published artifact Instrument: `curl` the Release asset through the REST API, then recompute the delta with a **hand-written flattener in Python** (not this repo's code) over the two published tarballs' own `api-surface/` shard directories. | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **Release asset** `aggregate.added` | **225**, `since` counter `{17: 225}` | | same asset, `aggregate.removed` | **51**, `removedIn` counter `{17: 51}` | | same asset, `aggregate.from` / `aggregate.to` | `10` / `17` | | same asset, `perMajor[16 → 17]` | `added: 0, removed: 0` (converted 57, migrated 77) | | same asset, `release` section | **absent** (generated 2026-09-09, before objectstack-ai#18889) | | independent recompute, `npm pack` 17.3.0 vs 17.4.0 `api-surface/` | **added 225, removed 51** | | set equality, asset arrays vs recompute | `added` **True**, `removed` **True**; 0 only-in-asset, 0 only-in-recompute, both directions, both arrays | So the published arrays are, byte for byte, the **17.3.0 → 17.4.0** one-minor delta, wearing a `10 → 17` label. Cross-check: PR objectstack-ai#17080's own changeset states the same pair as "gained 225 exports and lost 51". ### One refinement to the card's premise, stated because it moves a date, not a verdict | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | `@objectstack/spec@17.3.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | npm publish time of 17.4.0 | `2026-09-09T03:57:51.929Z` | | merge time of objectstack-ai#18889 (`8b4890343`) | `2026-09-18T11:16:13+00:00` | ⇒ **no published tarball carries the mislabelled arrays yet.** The lane that will is on `origin/main` today: `release.yml` runs `release-spec-changes.sh --prepare` (line 1251) and `--verify` (1260) **before** the publish, then `--attach` (1355), and `--prepare` invokes the generator with `--previous-package`. The card's "reach is new" premise therefore holds as a property of the lane, and the first tarball to carry it is the next publish. Today's carrier is the Release-page asset, measured above. This is a sharpening, not a disproof — nothing in the card's argument depends on a tarball already existing. --- ## 2 · The A/B legs, re-taken Base: `origin/main` at `07c6f822e`. Previous artifact: `npm pack @objectstack/spec@17.3.0`, unpacked. Both legs write the real snapshot path, so each was copied out and the tree restored by `git checkout HEAD -- packages/spec/spec-changes.json` with the blob hash re-read each time (`9dbc98682…` in, `9dbc98682…` out, `git diff HEAD` empty, `git status --porcelain` empty). | leg | what ran | result | |:--|:--|:--| | **A** | HEAD generator, `--previous-package PKG_DIR` | `aggregate.added` **399** (`since` counter `{17: 399}`), `aggregate.removed` **302** (`removedIn` counter `{17: 302}`), `perMajor[16 → 17]` **0 / 0**, `release` 17.3.0 → 17.4.0 with 399 / 302 | | **B** | generator at `43f4766889e` — objectstack-ai#18889's parent, verified **0** occurrences of the string `--previous-package` on disk and 3 of `--previous-surface` — invoked with `--previous-surface` | `aggregate`, `perMajor`, `protocolVersion`, `supportFloor`, `migrateCommand` all **canonical-hash identical to leg A** (`aggregate` = `d8c3e5c4303c2ecc` on both) | Whole-document diff between the two legs: the `release` key (leg A only) and `$comment` (which objectstack-ai#18889 extended). Nothing else. ⇒ **the computation is pre-existing**, exactly as the card claimed. One reading the card did not state, and it is the sharpest one: in leg A, `aggregate.added` / `aggregate.removed` are **set-identical to `release.added` / `release.removed`**. The aggregate record does not merely resemble a one-release slice — it *is* the release slice, under a major-resolution header. --- ## 3 · ⭐ The consumer survey the card named as unmeasured **Question:** who reads `aggregate.added` / `aggregate.removed` today? ### Radius, declared | # | in radius | how read | |:--|:--|:--| | R1 | `objectstack-ai/objectstack` @ `origin/main` `07c6f822e` | `git grep -I` over tracked **and** untracked files, whole tree, no `head` anywhere | | R2 | `objectstack-ai/objectui` @ `origin/main` `05a49f2ee` (fetched for this survey) | `git grep -lI PATTERN origin/main` | | R3 | the published tarball's own contents | `npm pack` 17.3.0 and 17.4.0, plus `packages/spec/package.json` `files[]` | | R4 | the documented / prescribed consumers | `content/docs/upgrading.mdx`, `skills/objectstack-upgrade/SKILL.md` (the **published** skill catalog), `docs/adr/0087` | **Outside the radius, named as outside it:** the `objectstack-ai/cloud` repository (not checked out in this container); any third-party or private consumer of the npm artifact or of the Release-page asset; and the `spec_changes` MCP tool, which is **prose only** — `git grep spec_changes` over R1 returns docs, ADRs, changelogs and code comments and **zero implementation**, so there is nothing there to read anything. ### Instrument, in two stages - **Stage 1 — population.** Every site naming the literal `spec-changes.json`, plus every site naming a key that is distinctive to this manifest (`perMajor`, `supportFloor`). Enumerable and small; each hit was then read. - **Stage 2 — field classification.** For each member of that population, which top-level keys it actually reads. ### ⭐ Lit controls, so a zero is a reading | control | instrument | result | |:--|:--|:--| | L1 · a site that provably reads a field of `spec-changes.json`, found by stage 1 | `git grep -n "spec-changes\.json"` | **found** `packages/cli/src/utils/spec-release-changes.ts:80`, which reads `doc.release` at line 106 — a real, shipping reader | | L2 · the distinctive-key instrument is not dead | `git grep -n perMajor` / `supportFloor` | **found** the producer, two gate fixtures, and the published `skills/objectstack-upgrade/SKILL.md:219` + its `node -e` snippet at 229-236 | | L3 · the instrument reaches objectui at all | `git grep -lI PATTERN origin/main` in `../objectui` | `@objectstack/spec` → **1628** files; `api-surface` (another published spec artifact) → **3** files | ### Result | consumer | radius | reads | reads `aggregate.added` / `removed`? | |:--|:--|:--|:--| | `packages/cli/src/utils/spec-release-changes.ts:106` (ships in `@objectstack/cli`) | R1 / R3 | `doc.release` and the lengths of its four arrays | **no** | | `scripts/check-release-spec-changes.mjs` `aggregateIds()` | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId`, `release.*` | **no** | | `packages/spec/scripts/build-spec-changes.ts` `previousRelease()` (reads the PREVIOUS tarball) | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId` | **no** | | `scripts/check-adr-0087-registration.mjs` (parser-rot witness) | R1 | `migrationId` occurrences | **no** | | `skills/objectstack-upgrade/SKILL.md` — **published** to customer projects | R4 | `perMajor[].converted`, `perMajor[].migrated`, `protocolVersion`, `supportFloor` | **no** | | `content/docs/upgrading.mdx` | R4 | `.release.*`; and, for withdrawals only, `.aggregate.converted[].conversionId` / `.aggregate.migrated[].migrationId` | **no** | | whole `objectui` repository | R2 | nothing — `spec-changes` → **0** files, `perMajor` → 0, `supportFloor` → 0, `spec_changes` → 0 | **no** | | `spec_changes` MCP tool | R1 / R4 | does not exist as code | n/a | | `scripts/regen-artifacts.mjs`, `check-regen-pending.mjs`, `objectui-changeset-digest.mjs`, `check-published-files.mjs`, `docs-audit/affected-docs.mjs` | R1 | the **path**, as a ledger row — never a field | **no** | ⇒ **Zero readers of `aggregate.added` / `aggregate.removed` in the reachable radius.** Every field-level reader of `aggregate` reads `converted` / `migrated` only. Confirming probes: `git grep -nE "aggregate(\.|\[[\"'])(added|removed)"` over R1 returns **0 rows**; the loosened, case-insensitive variant returns 11 rows, all the English phrase "aggregate added to the spec" about SQL aggregate functions. **But there is a declared contract, and it is the one the defect breaks.** `content/docs/upgrading.mdx:338` says, of this very field: "The same file's `aggregate` and `perMajor` records are unchanged and still answer the **major-boundary question**." They do not. That sentence is the class-(b) contract text — a machine-readable surface that does not say what it means — and it is what makes this a defect rather than an unused field. ### Why the survey licenses the shape taken The dispatch allows two shapes: **gate** the aggregate arrays as `release.*` is gated, or **relabel** them at the resolution they actually carry. Gate-only cannot be the whole fix here, and that is a measurement, not a preference: there is no computable "correct" 10 → 17 export delta to gate against, because tarballs before protocol 15 ship no `api-surface` snapshot at all. A gate that merely refused today's shape would wedge every release until the producer changed — and the producer changing **is** the relabel. So the gate is not an alternative to the relabel; it is the **negative control for it**. And with **zero** readers, relabelling is free: nothing downstream can break, so the honest fix is available at no migration cost. That is what the survey buys. ⛔ **Not taken, and reported instead:** removing the fields, or ceasing to emit them. The survey lands exactly where the card guessed it might — nobody reads them — so the removal question is live, and it is the maintainer's. See `## Acceptance notes`. --- ## 4 · What changed A record whose export arrays are non-empty now carries the version pair they were diffed between: ```json "aggregate": { "from": 10, "to": 17, "surfaceScope": { "fromVersion": "17.3.0", "toVersion": "17.4.0" }, "added": [], "removed": [] } ``` - **`packages/spec/src/migrations/spec-changes.ts`** — `SpecSurfaceScopeSchema` + `SpecSurfaceScope`, an optional `surfaceScope` on `SpecChangesSchema`, `SurfaceDiff.scope`, and `surfaceScopeProblem(record)`, which is the refusal. The record spreads the key in rather than assigning `undefined`, so a record with no export diff serialises exactly as before. - **`packages/spec/scripts/build-spec-changes.ts`** — reads the previous version off the previous artifact's own `package.json` (`--previous-package PKG_DIR`, or the sibling of a `--previous-surface` snapshot), OMITS the arrays loudly when it cannot, and refuses outright to write a non-empty unlabelled array. - **`scripts/check-release-spec-changes.mjs`** — `verifyAggregateSurface()` recomputes the aggregate's claim from the same two tarballs the release section is checked against, and refuses an absent, mislabelled or untrue scope in both directions. Self-test roster **15 → 23** batteries. The failure headline now names which claim disagreed. - **`packages/spec/src/migrations/spec-changes-surface-scope.test.ts`** — new. - Regenerated: `packages/spec/spec-changes.json` (one line — its `$comment`) and `packages/spec/api-surface-declarations/root.txt` (+6 / -0). ⛔ **Not narrowed on purpose.** `SpecChangesSchema` still accepts an unscoped diff, because every manifest published so far carries one and a schema that refused them would narrow what an already-shipped artifact parses as. The refusal lives at the producer and at the publish gate. ⛔ **`packages/spec/src/migrations/registry.ts` was not touched** (held by objectstack-ai#19095, objectstack-ai#19090, objectstack-ai#19084, objectstack-ai#18319). The change is additive, so it declares no ADR-0087 disposition and needs no migration entry: `node scripts/check-adr-0087-registration.mjs --base origin/main` → "this PR adds no declared-breaking changeset". `scripts/regen-artifacts.mjs` (held by objectstack-ai#19024) and `content/docs/releases/**` were not touched either. The public entry barrel `packages/spec/src/migrations/index.ts` was deliberately left alone, which is why `check:api-surface` is green with no export-name churn. --- ## 5 · ⭐ Acceptance controls ### Control 1 — a test that fails on today's composition (acceptance 1) Ablation via `node scripts/ablation-replace.mjs`, which proves the mutation reached disk before running anything: ```text ablation-replace: anchor "...(surfaceDiff.scope ? { surfaceScope: surfaceDiff.scope } : {})," x1 (before) ablation-replace: anchor x1 -> x0 ablation-replace: replace "// ABLATION: the composer drops the scope..." x0 -> x1 ablation-replace: blob 2e046d0 -> d0c1189d0f233a8d46b2641812713acf33a50181 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2e046d0 -> d0c1189d0f23 VITEST_EXIT=1 Test Files 1 failed (1) Tests 2 failed | 5 passed (7) ablation-replace: blob after restore 2e046d0 ablation-replace: blob at HEAD 2e046d0 ablation-replace: ok restored: blob == HEAD (2e046d0) and `git diff HEAD` is empty ``` The reported failure is the real one: `expected 'the 10 → 17 record carries 2 added and 1 removed export(s) with no surfaceScope…' to be null`. Unablated: **7 / 7 pass**. No ablation artefact remains — restore proved by blob equality with `HEAD` and an empty `git diff HEAD`, not by an exit code.⚠️ Reported honestly: the first run of this ablation piped vitest into `tail`, so the wrapper printed `command exited 0` while the suite had failed. The run above redirects first and captures `$?` before any pipe. Only the second reading is cited. ### Control 2 — ⭐ preserved truth (acceptance 2), shown rather than asserted Same generator invocation, same real 17.3.0 tarball, before the fix and after; every record compared by canonical JSON: ```text perMajor identical=True protocolVersion identical=True supportFloor identical=True migrateCommand identical=True release identical=True aggregate MINUS surfaceScope identical=True (the only added key: {'fromVersion': '17.3.0', 'toVersion': '17.4.0'}) $comment identical=False (documents the new key) ``` And on the **committed** artifact, per-key against `HEAD`: `aggregate` unchanged, `perMajor` unchanged, `protocolVersion` unchanged, `supportFloor` unchanged, `migrateCommand` unchanged, `$comment` changed — a one-line diff (`1 insertion, 1 deletion`). The per-release section objectstack-ai#18889 added is untouched in both readings, and `composeReleaseChanges` still returns exactly its six keys (pinned in the new test). Two further preserved-truth readings: all **15** pre-existing gate self-test batteries still pass unchanged, and `pnpm --filter @objectstack/spec check:generated` reports "All 16 generated artifacts are up to date". ### Control 3 — ⭐ a negative control that distinguishes fixed from switched off (acceptance 3) The gate run against four constructed publish trees, each carrying the real committed `api-surface/` and a real `package.json`, with the real unpacked 17.3.0 tarball as `--previous`: | input | what it is | gate | |:--|:--|:--| | `good` | the post-fix generator's own output | **EXIT=0** — "release 17.3.0 → 17.4.0 verified … 399 added, 302 removed … aggregate export diff 17.3.0 → 17.4.0 verified: 399 added, 302 removed." | | `bad-prefix` | the **genuine, unmodified pre-fix artifact** — what `main`'s generator produces today | **EXIT=1** — "aggregate.surfaceScope is absent while aggregate.added/removed carry 701 export(s). … Expected { fromVersion: "17.3.0", toVersion: "17.4.0" }." | | `bad-unscoped` | post-fix output with `surfaceScope` deleted | **EXIT=1**, same refusal | | `bad-wrongscope` | `surfaceScope.fromVersion` set to `17.2.0` | **EXIT=1** — "the export diff was taken against a different release." | The `bad-prefix` row is the load-bearing one: the new gate refuses the artifact today's code actually produces, so it is a check that can still fail rather than one that was switched off. Eight further refusals are pinned as self-test batteries (absent scope, wrong `fromVersion`, wrong `toVersion`, an invented export, an omitted real removal, a claim the previous tarball could not have produced), each alongside two GREEN batteries — a matching scoped claim, and the unscoped-empty registry-only projection that must stay accepted. The producer half, both directions: ```text $ tsx scripts/build-spec-changes.ts --previous-surface ORPHAN_DIR/api-surface No aggregate export diff: the previous artifact at ORPHAN_DIR/api-surface carries no readable package.json, so the version pair the diff spans cannot be read. Omitting `added`/`removed` — an unlabelled one-release slice under the major-keyed aggregate record reads as the whole from → to delta. -> aggregate added 0 removed 0 surfaceScope None $ tsx scripts/build-spec-changes.ts --previous-surface PREV_PKG/api-surface -> aggregate added 399 removed 302 surfaceScope {'fromVersion': '17.3.0', 'toVersion': '17.4.0'} ``` ### Control 4 — the card's own numbers, re-measured after the change (acceptance 4) Instrument: HEAD generator, `--previous-package` pointed at the unpacked published 17.3.0 tarball; counters computed by `collections.Counter` over the emitted JSON. | reading | post-fix value | |:--|:--| | `aggregate.from` / `to` | `10` / `17` (unchanged — it still answers the major question for `converted` / `migrated`) | | `aggregate.added` | 399, `since` counter `{17: 399}` | | `aggregate.removed` | 302, `removedIn` counter `{17: 302}` | | `aggregate.surfaceScope` | `{fromVersion: 17.3.0, toVersion: 17.4.0}` ← **new; this is the fix** | | `perMajor[16 → 17]` | `added: 0, removed: 0` (unchanged, and now honest by construction: the record says nothing about exports) | | `release` | 17.3.0 → 17.4.0, 399 added / 302 removed (unchanged) | --- ## 6 · Verification | what | result | |:--|:--| | `pnpm --filter @objectstack/spec build` (forced fresh, under the shared verify lock) | `VERDICT command-exit 0`; `check-dts-emitted: 34/34` | | `pnpm --filter @objectstack/spec typecheck && … test` (under the lock) | `VERDICT command-exit 0` — **495 test files, 14527 tests, all passing** | | `node scripts/check-release-spec-changes.mjs --self-test` | EXIT=0 — **23 batteries pass** (15 pre-existing + 8 new) | | `pnpm --filter @objectstack/spec check:generated` | EXIT=0 — all 16 artifacts up to date | | `pnpm lint` (full repo union, at final commit `0c548868c`) | EXIT=0 — **6878 files linted, 0 errors, 0 warnings** (`--format json` counts) | | `pnpm check:nul-bytes` | EXIT=0 — 8952 text files, no raw control bytes | | `@objectstack/cli` unit tier, `src/utils/spec-release-changes.test.ts` | 6/6 pass — the one downstream reader of this artifact | | gate families derived from the diff (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`) | 103 commands over 7 paths; **43 run green** locally, listed in the report | | `pnpm check:type-check-debt` | **EXIT=3 · PREREQUISITE NOT MET — NOT MEASURED**: `--re-measure` needs the whole workspace build closure on disk and only `packages/spec` was built. Its own words: "This is NOT a pass and NOT a finding". Its non-re-measure invariants reported 0 findings on all three layers. Left to CI, which builds the closure first. | Downstream reach, with a lit control: `git grep -nE "\b(SpecChangesSchema|SurfaceDiff|SpecSurfaceAdd|SpecSurfaceRemove)\b"` outside `packages/spec` returns **0 rows**; the same instrument finds `composeMigrationChain` (a sibling export of the same module directory) in `packages/cli/src/commands/migrate/meta.ts`. ⇒ no package outside `packages/spec` names any changed declaration, so no other package's tests are owed. Export **names** are unchanged (`check:api-surface` green); only declaration text moved (`api-surface-declarations`, +6 / -0). --- ## Acceptance notes 1. ⭐ **The removal question is live, and it is the maintainer's.** The survey found **zero** readers of `aggregate.added` / `aggregate.removed` in the whole reachable radius. The card itself floats "if the answer is nobody, the cheapest honest fix may be to stop emitting it". It was ⛔ **not** implemented here — removing a published machine-readable capability is a maintainer decision — and this PR makes the surface honest instead, which is strictly compatible with a later removal. Recorded as an open question. 2. **`content/docs/upgrading.mdx` is corrected here, not merely reported.** Line 338 said 'The same file's `aggregate` and `perMajor` records are unchanged and still answer the major-boundary question'. That is true of `perMajor`, and of `aggregate.converted` / `aggregate.migrated`, which are registry-derived across the whole range — and it was never true of `aggregate.added` / `aggregate.removed`. The page was the declared contract this artefact did not keep, so correcting it is the doc half of this defect rather than opportunistic cleanup. The path was measured FREE of open-PR holders first (32 open PRs, 364 file rows, instrument lit by all four holders of the migrations registry). 3. **A deliberate boundary in the new gate, so nobody reads it as an oversight.** It refuses a *wrong* aggregate claim; it does not *require* the published artifact to make one. An aggregate with empty arrays and no `surfaceScope` is accepted, because that is the honest registry-only projection. Turning "must not lie" into "must speak" would be a new publish requirement, and that call is not this gate's. The residual hole is narrow: a bug that silently emptied `aggregate.added` while `release.added` stayed correct would pass. Worth a card if the maintainer wants the stronger rule. 4. **`--previous-surface` has no caller left in the repository.** `git grep -- "--previous-surface"` finds only the generator's own argv parsing and its docblock; every lane uses `--previous-package` (`scripts/release-spec-changes.sh:87`). It was kept working — and taught to derive its scope from the snapshot's sibling `package.json` — rather than retired, because retiring a flag is not this card. 5. **`cut-rc.yml` attaches, and never prepares.** It calls `bash scripts/release-spec-changes.sh` with no mode, which defaults to `--attach`, so the RC lane uploads the committed registry-only manifest and never runs `--verify`. Not a defect (the committed copy claims nothing), and not this card — noted because it is the one lane the new gate never sees. 6. **No label was applied by this PR.** `Clause-②: yes` means it and objectstack-ai#18978 owe `needs:contract-review`; that label is the seat's to apply and ⛔ never this branch's to clear. 7. **The two regenerated artefacts were written by the repo's own generators, never by hand.** `packages/spec/spec-changes.json` by `pnpm --filter @objectstack/spec gen:spec-changes`; `packages/spec/api-surface-declarations/root.txt` by `pnpm --filter @objectstack/spec gen:api-surface-declarations`. Both were named stale by `pnpm --filter @objectstack/spec check:generated` first, and only those two were regenerated (`--fix` is deliberately narrow). No `origin/main` merge was performed on this branch, so the `merge=os-regen` silent-resolution hazard on that path was never entered. 8. **The docs-drift bot's three hand-written rows, answered.** `content/docs/api/client-sdk.mdx` and `content/docs/kernel/contracts/metadata-service.mdx` are **still accurate**: both were anchored by a NAME COLLISION on the generic identifiers `fromVersion` / `toVersion` between this PR's new published-version STRINGS and the REST metadata-history routes' INTEGER version parameters (`rest-server.ts:8209` reads `body.toVersion` for `POST /meta/:type/:name/rollback`; `client-sdk.mdx:229-230` spells the SDK keys `from` / `to`; `metadata-service.mdx:87` declares `version: number`). Neither page mentions `spec-changes` at all. `content/docs/upgrading.mdx` is the one genuinely-mine row and is corrected in this PR. ⛔ `content/docs/releases/v17/17-1.mdx` is release-owned and was not edited — it is also **not wrong**: the same collision put it there, its only mention of the route is line 294 in a security context, and it never names `spec-changes`. 9. **The bot's own blind spot, answered by reading rather than by trusting its run.** It declared that `api-surface-declarations/root.txt` and `spec-changes.json` yielded no anchor, so pages documenting those are outside its run — and `spec-changes.json` is this card's subject. A full read of `content/docs/**`, `docs/**` and `skills/**` finds **exactly one** page stating a claim about the aggregate export arrays' resolution: `upgrading.mdx:338`, corrected here. The published `skills/objectstack-upgrade/SKILL.md` points only at `perMajor[].converted` / `perMajor[].migrated` / `protocolVersion` / `supportFloor` — all unaffected and all still true. `content/docs/releases/v15.mdx:521-523` claims only that the file is generated, ships and attaches: still accurate. `docs/adr/0087:210-213` states no falsehood (its 'compose' claim is about the registry-derived arrays), though it is where the ambiguity originates — a governed-surface question, left to the maintainer. <sub>⚠️ Notes 2, 7, 8 and 9 were written into this body by the dispatching seat (`Seat: domain:spec#3`, `session_019srGWGCBBCBHqcDoRZpQRh`) at 2026-09-18T21:06Z, from the implementing dev's final report. The dev correctly refused to PATCH this body: `.claude/agents/os-dev.md:56` says the PR body is written once, on the call that opens the PR, and later corrections are named in the report for the seat to write — and `:184` makes that clause govern over any dispatch word. ⛔ Nothing else in this body was touched, and ⛔ no verdict about the diff is written here: the clause-② review is an isolated at-tier reviewer's, and `needs:contract-review` stays on both carriers until it lands.</sub> --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…hipped prescriptions were denying a door that exists (objectstack-ai#19234) Fixes objectstack-ai#17487 Clause-②: no ## The defect, and its direction Three shipped, customer-facing prescriptions in `@objectstack/spec` stated in the present tense that the runtime confirmation door had not shipped. It has: `actionConfirmationRefusal` is called pre-dispatch by `invokeBusinessAction` in `@objectstack/runtime`, and the MCP `run_action` tool grew the `confirm` member in the same change (the card behind it, objectstack-ai#15942, is done — `state_reason=completed`; its changeset `action-confirmation-gate-enforced` is still pending, so the door is on `main` and not yet released). So the published text denied a door that exists, and it failed in the dangerous direction: an author who reads it concludes the safety flag stops nothing, and either arranges a human in the loop some other way or stops setting the flag — losing the gate at the moment it starts working. That is the ADR-0049 false-compliance class with the sign flipped. ## Re-derivation — all three sites read on today's `origin/main` Triage's unblock comment verified site 1 only and said the other two were unmeasured. All three were re-read at merge base `805811e0d`. | # | Path | Current text | Verdict | |---|---|---|---| | 1 | `packages/spec/src/ai/tool.zod.ts` — `TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation` | "the declaration is the contract, not yet the behaviour — the runtime door that performs the refusal ships separately, and until it does, setting the flag does NOT stop an unconfirmed call. Do not try to verify the gate by invoking the operation without the member: until that door lands, such a call simply RUNS." | **FALSE today** | | 2 | `packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts` — `replacement` | "The refusal is DECLARED, not yet performed — the runtime door lands in objectstack-ai#15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange" | **FALSE today** | | 3 | the same file — `acceptanceCriteria` | "Do NOT try to 'prove the gate' by invoking the operation without the confirmation member: the runtime door that refuses lands in objectstack-ai#15942, so before that ships the call is not refused, it RUNS the destructive operation." | **FALSE today** | **Correction to the card's count of the carriers.** The card names the `spec-changes` entry, the upgrade guide and the `os migrate meta` projection as if they were separate sites. They are not: all three are projections of the **one** ADR-0087 D3 entry file above. The measurement is therefore **three false prescriptions living in two source files**, plus three generated artefacts that carry them (`src/migrations/registry.ts`, `spec-changes.json`, `docs/protocol-upgrade-guide.md`), all regenerated here by `check:generated --fix`. Sweep radius for "is that all of them": eleven denial phrasings grepped repo-wide (`not yet the behaviour`, `ships separately`, `not yet performed`, `stops nothing`, `simply RUNS`, `until it does`, `until that door`, `door lands`, `yours to arrange`, `nothing server-side`, `no pause`), with `requiresConfirmation` lighting 10 files under `packages/spec/src` as the positive control. Two adjacent texts were read and left alone as **NOT A DEFECT**: `packages/spec/src/contracts/ai-service.ts` already states the gate in normative present tense, and `content/docs/ai/tools.mdx` says the retired **tool**-level key "returns only together with its enforcement", which is still true — the tool key has not returned. Two further readings are recorded under *Acceptance notes*. ## What the prose says now, and what holds it there Each prescription now states the refusal in the present tense **with the door's bounds**, because an unbounded "the platform refuses unconfirmed calls" is this same defect in the other direction. Read off the door's own docblock and its shipped changeset, never inferred: - the refusal is `ACTION_CONFIRMATION_REQUIRED`, 428, naming the action and the member `confirm: true`; - a GATE, not a queue — nothing is parked, and a refused call did not run: the gate sits before `loadActionSubjectRecord`, so no record is read and none written; - the enforced set is the doors that enforce the author's `ai.exposed` opt-in — today the action door reached from MCP `run_action`. REST `/actions` is **not** `ai.exposed`-gated and sits outside the gate, so an API-key agent on that route still needs its own human; - only the author's declared `ai.requiresConfirmation: true` refuses, and only the boolean `true` confirms; the wider `list_actions` heuristic advises and never refuses; - `confirm: true` is an unverifiable caller claim: the gate makes forgetting loud, it does not prove a human. `packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was missing the first time — the prose was never bound to the function it describes, which is how it rotted. It reads the three shipped strings **and** the runtime door, and fails in both directions. **No pin was moved.** `ui/action-requires-confirmation-docblock.pin.test.ts` was read: it anchors on the `ai.requiresConfirmation` JSDoc in `ui/action.zod.ts` and on `actionLooksDestructive`, neither of which this diff touches, so it covers none of the three sites and stays as it is. ## Clause-②: no — the accept set did not move `check:authorable-surface` and `check:api-surface` are green with **zero** diff under `packages/spec/authorable-surface/` and `packages/spec/api-surface/`. The pin's last case feeds the same authored metadata in before and after: `tool.requiresConfirmation` still refused, a minimal tool still accepted, `action.ai.requiresConfirmation` still accepted for both `true` and `false`. What moved is string content inside `dist` and `spec-changes.json`, which is why a `patch` changeset is owed and present. ## Tests, and the reverse verification `pnpm --filter @objectstack/spec test` — 499 files / 14614 tests passed. `test:repo` — 34 files / 580 tests passed. `typecheck` — clean. New pin: 8/8. Three ablation legs, each mutated on disk through `scripts/ablation-replace.mjs` (anchor hit declared, blob hash proven to move), direction predicted before the run, restored and proven by blob hash against `HEAD` with `git diff HEAD` empty: | leg | mutation | predicted | observed | |---|---|---|---| | 1 | re-insert `The refusal is DECLARED, not yet performed` into the D3 entry's `replacement` | RED on "no shipped prescription denies the refusal" | RED, naming the replacement carrier | | 2 | rename the gate call inside `invokeBusinessAction` | RED on "the AI-facing door still calls the gate pre-dispatch" | RED | | 3 | make the REST `/actions` door name the gate | RED on the over-claim guard | RED | Leg 3's **first attempt was a no-op** and is reported as such: the replacement text still contained the anchor, so `ablation-replace` refused (anchor drop 0, not the declared 1) and nothing ran. It was re-anchored and re-run; the reading above is the re-run. ## Gates All 85 commands derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` for this diff were run locally and exit 0, exit codes captured before any pipe. Eight first returned a stale-`dist` or `PREREQUISITE NOT MET` result (exit 1 / exit 3 — not measured, not findings); they were re-run green after `pnpm --filter @objectstack/spec build` and a full `turbo run build` closure. `pnpm lint` (`eslint . --no-inline-config`, whole repo, no narrowing) exits 0 at `HEAD`. CI still owns its own farm: the five path-scheduled CI jobs, the 11 wide-population families and the artifact rosters are outside that 85 and are NOT MEASURED here. ## Acceptance notes Two readings taken while re-deriving, both **out of scope for this card** and neither edited here: 1. `packages/spec/docs/MCP_GUIDE.md` (around the "Side Effects" section) tells an author to gate side effects with "`ai.requiresConfirmation` on the underlying **action** (+ the HITL approval queue)" and then warns, in the adjacent block, that "nothing server-side pauses on it". The warning is correctly scoped to the MCP capability descriptor in that page's examples and is true of it; but the approval-queue requirement now overstates what the action-level flag needs, and the two paragraphs read together in the card's own dangerous direction. Not in the declared file surface. Reported for filing with dedupe words: `MCP_GUIDE`, `requiresConfirmation`, `HITL approval queue`, `nothing server-side pauses`, `confirmation gate`. 2. `content/docs/ai/actions-as-tools.mdx` — the "Human-in-the-loop approval" section still says that on the open MCP path "the approval step lives at the protocol boundary" (client-side prompting), and the numbered open-MCP action-gate list enumerates five gates without the confirmation gate that now sits between the param contract and the subject-record load. An omission against a contract that `@objectstack/spec/contracts` declares. Reported for filing with dedupe words: `actions-as-tools`, `human-in-the-loop`, `protocol boundary`, `run_action`, `confirmation gate`. Noted, not filed: `packages/spec/src/api/error-code-ledger.zod.ts` says of the `ACTION_CONFIRMATION_REQUIRED` row that "the door will assert this exact string by value" — a forward tense about something that is now true. It misleads nobody about the gate and it is provenance prose about the row's split registration, not a prescription. Successor: the next change that touches that ledger row. ## Occupancy Re-scanned at 2026-09-20T01:52Z over all 21 open PRs, with PR objectstack-ai#17076 (639 files) fully paged so no path is under-read. `packages/spec/src/ai/tool.zod.ts`, the D3 entry, `spec-changes.json`, `docs/protocol-upgrade-guide.md`, `vitest.repo-tests.json` and `src/ai/tool.test.ts` all read FREE. Firing controls in the same scan: `packages/spec/src/ui/component.zod.ts` HELD by objectstack-ai#19219, `packages/spec/src/ui/view.test.ts` HELD by objectstack-ai#19226; dark control (a nonexistent path) reads FREE. One reading to flag: `packages/spec/src/migrations/registry.ts` reads HELD by objectstack-ai#19223, objectstack-ai#19090 and objectstack-ai#18319 — it is a generated, `merge=os-regen` artefact and none of those three touches the D3 entry this diff edits, so the contention is the one the regen driver exists for rather than two hands on the same prose. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…eaches the author with no logger injected (objectstack-ai#19281) Fixes objectstack-ai#18570 Clause-②: no ## What this changes — one site, the delivery only `packages/plugins/plugin-security/src/seed-name-lookup.ts` reported the **batched existence read's own failure** through a doubly-optional `logger?.warn?.(…)`. That call evaluates to NOTHING when the caller injected no sink: the read failed, the oracle silently degraded to the per-item read, and no human was told. It now reports through **`reportThroughSink`** — the single derivation objectstack-ai#18091 landed for exactly this rule, so this sixth instance of the shape (objectstack-ai#17516 · objectstack-ai#18023 · objectstack-ai#18091's five sites) does not become a sixth hand-written copy. ⛔ **Exactly one site.** The triage seat bounded the caliber in as many words and it is not widened here: > 修法是**那一个站点**,⛔ 不是顺手把全仓 `logger?.warn?.(` 都改 —— 其余站点各自的 caliber 归各自的卡。 No other `logger?.warn?.(` spelling is touched, in this package or anywhere else. `permission-set-projection.ts`, `bootstrap-declared-permissions.ts` and `PermissionSeedOutcome` are untouched — objectstack-ai#18571 owns that surface. **Delivery only.** The wording, the structured meta (`object`, `names`, `rowBudget`, `organization`) and the two named causes — `unreadable` and `truncated` — are axis-specific and stay at the call site, which is the split `seed-refusal-sink.ts` documents. A read that ANSWERED is as silent as it always was, on every channel. **Why the call site asks `typeof` rather than handing the parameter straight over:** `SeedLookupLogger.warn` is OPTIONAL where `CollisionReportSink.warn` is not, so the host is handed over only once it really carries a callable `warn` — the same question `reportThroughSink` itself asks, moved one frame up so the declared types stay honest instead of being asserted past. ⛔ `seed-refusal-sink.ts` is not edited. ## The evidence bar — a DRIVEN, differential reading, because that is how the card was graded New suite `seed-name-lookup-degradation-audible.test.ts`, five cases. Case 1 is the card's own shape: an unreadable-database pass with **no logger argument at all**, run beside a **lit control in the same run** — `reportCapabilityRowsUnreadable`, an already-repaired objectstack-ai#18091 site that reaches the console with no sink. A zero from the site under repair is a reading only while that control fires. The other four are what keeps case 1 from being satisfiable by an unconditional `console.warn`: | case | what it pins | |---|---| | 2 | the `truncated` cause travels the same way, `rowBudget` and all | | 3 | an injected sink still gets the line **with its receiver** (a class-based host), and the console stays out of it | | 4 | a read that **ANSWERED** is silent on every channel with no sink — the discriminating control objectstack-ai#18023 established | | 5 | a host whose `warn` is not callable reaches the author **without throwing** | ## Reverse verification — the ablation, run from the committed state The fix was committed first, then `seed-name-lookup.ts` alone was restored to the merge base on disk (proved landed: `reportThroughSink(` count 1 → 0, blob `4d7c35fe` → `83629f98`), the suite re-run, and the file restored to `HEAD` (proved: `git diff HEAD` empty, on-disk blob back to `4d7c35fe`). Ablated run — **3 of 5 red**, and the failure lines are the measurement: - case 1 failed at the site-under-repair assertion while the **lit-control assertion above it passed** — i.e. the pre-fix run printed the control line ALONE. That is the card's reading reproduced: ONE author-visible line, this site silent. - case 2 failed the same way (`expected [] to have a length of 1`). - case 5 failed with `TypeError: logger?.warn is not a function` thrown out of `buildExistingByName` — so the old spelling did not merely mute a sink-less host, it **aborted the seeding pass** for a host that declared `warn` and shipped something else. `?.()` guards `null`/`undefined`, never a non-callable. - cases 3 and 4 stayed green pre-fix, exactly as predicted: the mute was never on the injected arm, and a healthy pass was always quiet. ## One file outside the declared surface, named here with its evidence The dispatch's declared file surface was `seed-name-lookup.ts` + its test. One more file had to move, and it is this card's own harness rather than a second repair: `bootstrap-declared-capabilities.test.ts` → `[objectstack-ai#18091] UNREADABLE ROWS: prints with NO LOGGER INJECTED` asserted `expect(cap.seen).toHaveLength(1)`. **That ONE is the measurement objectstack-ai#18570 was filed on.** With both sites now audible the same pass prints two, so the pin reads `toHaveLength(2)` and selects each line by CONTENT (the read fails before the pass summarises, so the objectstack-ai#18570 line arrives first). Every content assertion the case already made is preserved, now against its own line. ⛔ No source file of that seeder is touched, and no other assertion in it moves. Measured before editing it: across **24** open PRs and **325** changed-file rows, `seed-name-lookup.ts` is held by **0** and `bootstrap-declared-capabilities.test.ts` by **0**, with a same-package firing control — PR objectstack-ai#18319 does hold `packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts` — so both zeros are readings, not a dead scan. ## What was measured Exit codes captured **before** any pipe, in every case. - `pnpm --filter @objectstack/plugin-security test` — **exit 0**, `Test Files 115 passed (115) · Tests 2233 passed (2233)`. (One pre-existing case went red on the first run — the control above — and that red is the finding, repaired as described.) - `pnpm --filter @objectstack/plugin-security typecheck` — **exit 0**. Three legs, read off the package's own script rather than assumed: `tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && pnpm check:test-typecheck`. The third reports `0 file(s) / 0 error(s) / 0 pinned signature(s)`; this package carries no `test-typecheck-debt.json`. - `pnpm --filter '@objectstack/plugin-security^...' build` — **exit 0** (dependency closure), and later the whole `./packages/*` + `./packages/*/*` closure, `72 successful, 72 total`. - **Gate set derived from the real change set**, not guessed: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` ⇒ **60 commands**. All 60 run, all **exit 0**. Reconciled with `--ran`: `60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit code and none of them is 3), 0 UNRUN`. - Three of the 60 answered **exit 3 = PREREQUISITE NOT MET = NOT MEASURED** on first run — `check:dual-build-cjs-loads`, `check:i18n`, `check:type-check-debt` — each naming a built-output prerequisite. The closure was built and all three re-run to **exit 0**. ⛔ None was reported as a pass on its 3. - `pnpm lint` (`eslint . --no-inline-config`, the repo-wide run) — **exit 0**. Run whole; no narrowing claimed. - Exported surface unchanged: `src/index.ts` re-exports nothing from `seed-name-lookup.ts`, and the built `dist/index.d.ts` carries `SeedLookupLogger` **0** times against a positive control of `SecurityPlugin` **22** times. ⇒ `Clause-②: no` still holds. Two verdict lines worth carrying out of logs that die with the worktree: - `check:engine-double-contract` — `UNRECOGNISED [engine-double-contract]: 9 construct(s) in packages, examples declare a scanned verb (delete, update, findOne) alongside engine siblings, and this gate could not read the implementation -- so they are in NEITHER the pinned population nor the ledger. 213 further construct(s) are SCOPED OUT by a stated criterion and are not counted here. This is a verdict, not a finding: it never fails a run (objectstack-ai#9747, ruling of 2026-08-18).` None of the nine is a file in this diff. - `check-changeset-no-major --base origin/main`, verbatim: ```text ℹ️ LEVEL AXIS: NOT APPLICABLE — this run has no `pull_request` to read a declaration from, so the clause-② axis has no input by construction rather than by omission. It is a PR-scoped reading: the RC cut (`cut-rc.yml`) and a local run reach here, and neither is a PR that could have declared. ``` ⇒ that axis is measured on this PR by CI, off the `Clause-②: no` line above. ## Changeset `patch` on `@objectstack/plugin-security`. Decided by reading, not habit: the package declares no `private` and ships `files: ["dist", "README.md", "CHANGELOG.md"]`, and `seed-name-lookup.ts` compiles into that `dist` — so this publishes. ## Acceptance notes - **noted, not filed** — `logger?.warn?.(…)` does not protect a host whose `warn` is present but not callable; it throws. That is a property of the spelling, so every remaining site in the class carries it, and each of those sites belongs to its own card (triage's bound above). Recorded here rather than filed because the class already has an owner: `reportThroughSink` is the repair, and the remaining sites are already enumerated on the sibling cards. 承接者: the seat that dispatches the next site in this class. - **noted, not filed** — `SeedLookupLogger` still declares `info?`, which nothing in `seed-name-lookup.ts` reads. Dead declaration, not a defect, and out of scope for a Clause-② `no` card. 承接者: 无. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…lifecycle state (objectstack-ai#19291) Fixes objectstack-ai#18877 Clause-②: no Maintainer decision batch objectstack-ai#157 item 5, letter C (ruling comment `5727164909`, director seat summon objectstack-ai#24): the install contract becomes 「缺省 = 保持,有旗 = 设置」. ## The defect `SchemaRegistry.initialDisabledPackageIds` is a **boot hydration input** — filled once, before any registration, from the durable disable file, and never updated by `enablePackage` / `disablePackage`. It was nevertheless consulted by **every** `installPackage` call, so once an id was in the boot seed set, every re-install within that boot re-landed it disabled whatever the operator had most recently done. Since PR objectstack-ai#18752 made the durable write follow the row the door returns, that stopped being memory-only: ```text boot 1 operator disables the package → disk lists the id boot 2 seeded from disk; the package installs disabled PATCH /packages/:id/enable → 200, registry true, disk CLEARED install(m, { overwrite: true }) (no flag) → the seed still listed the id → row disabled, disk written DISABLED boot 3 the operator's enable is gone, with no error anywhere ``` Reachable with nothing exotic: disable → restart → enable in Studio → an SDK upgrade with `overwrite`. ## What changed — the ruling's items 1, 2 and 3 **Item 1 · `packages/objectql/src/registry.ts` · `installPackage`.** The row is read **first**: an existing row keeps its own `enabled`, `status` and `statusChangedAt`, and the boot seed decides only for an id that has no row yet (boot hydration and a genuinely fresh install). A fresh id the seed never named still lands enabled, the declared default. `statusChangedAt` is carried, never restamped — a re-install is not a lifecycle move. **Item 2 · `packages/runtime/src/domains/packages.ts` · the install door.** `enableOnInstall: true` ⇒ `enablePackage`, `false` ⇒ `disablePackage`, **absent ⇒ no lifecycle call at all**. Only `false` was read before; the `true` case was carried by the re-install restamping every row enabled, so under item 1 it would have silently stopped working — the same «declared ≠ enforced» defect card objectstack-ai#18058 was about, pointing the other way. The bare (unwrapped) body form still honours nothing: no schema declares the key there, so it is always 「缺省」. **Item 3 · `DELETE /packages/:id`.** The id leaves the boot seed set with its row (inside `uninstallPackage`, downstream of the ADR-0029 refusal point, so a refused uninstall still removes nothing at all) and its durable disable entry is cleared. Previously that record was immortal: a delete left behind a disable naming a package that no longer existed. **Item 5 is not here.** It measured TRUE and rides on spec sub-card objectstack-ai#19273; this PR does not touch `packages/spec/**` at all, which is why it declares `Clause-②: no`. ## Item 4 — the pins that were re-ruled Every flip is an expectation change on an existing pin, ⛔ never a deletion. In `packages/runtime/src/domains/packages-install-enable-on-install.test.ts`: | pin | asserted before | asserts now | authority | |---|---|---|---| | `re-installing with the flag ABSENT …` | clears the durable disable — row / registry / disk all `true` | **preserves** it — row / registry / disk all `false`, plus `status` carried | ruling item 4, by name | | `a BARE re-install …` | clears it too, because that form cannot ask for `false` | **preserves** it — a body that cannot ask for a lifecycle change is 「缺省」 | ruling items 1 and 2 | | `a seeded id asked for enableOnInstall: true …` | SELF-CONSISTENT only — 「the seed wins」, no claim about which state it lands in | **ENABLED** — the flag outranks the seed; the self-consistency assertions are kept underneath the new one | ruling item 2 | **F1b stands unchanged**, exactly as the ruling says: every one of its cases is a seeded id whose install asks for nothing, which still lands — and stays — disabled. The `objectstack-ai#18058 MEASURED` matrix (`disk.has(id) === (row.enabled === false)`) is untouched and still holds on all eleven arms. New pins: `packages/runtime/src/domains/packages-install-preserves-lifecycle.test.ts` (the card's own scenario end to end — row, registry, disk **and** a restart — plus both 「有旗」 arms and both halves of item 3) and `packages/objectql/src/registry-install-preserves-lifecycle.test.ts` (the registry's own answer, which the door's pins structurally cannot see: on every door arm 「the registry preserved the row」 and 「the door re-applied the same state」 produce the same three records). ## Verification Full local suites of both affected packages, plus the derived gate set, at the final commit. | run | result | |---|---| | `pnpm --filter @objectstack/objectql test` | 301 files / 5018 tests passed | | `pnpm --filter @objectstack/objectql typecheck` | exit 0 | | `pnpm --filter @objectstack/runtime test` | 269 files / 3714 passed, 1 skipped | | `pnpm --filter @objectstack/runtime run test:repo` | 2 files / 69 tests passed | | `pnpm --filter @objectstack/runtime typecheck` | exit 0 | | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` | 63 families derived, 63 run, **all exit 0** | | `--ran` reconciliation | 63 derived, 63 accounted, 0 UNRUN, 0 NOT-MEASURED | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 — run whole, so no narrowing to justify | | four roster gates whose roster sits under these paths | `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:route-ledger-census` — all exit 0 | `pnpm check:dual-build-cjs-loads` first answered **exit 3 — PREREQUISITE NOT MET** (no `dist/` for 37 packages). That is not a pass and was not recorded as one: a full `pnpm build` (73/73 tasks) was run and the gate re-run to exit 0. ### Reverse verification — direction predicted before running, both legs proven on disk Both ablations went through `scripts/ablation-replace.mjs` (anchor must hit; the write is verified against the disk and the blob hash, never against an exit code) and `scripts/ablation-dist-preflight.mjs` (the mutation really reached the `dist/` the consuming suite reads, and really left it again). **A · revert item 1** — plant `ablation18877RevertsPreserve` so `installPackage` never sees the existing row. Marker present in 4 built files of `@objectstack/objectql`. Predicted red, observed red: 3 of 9 registry pins and 6 of 34 door pins failed, including the card's own end-to-end scenario and both flipped ABSENT pins. Restored: blob `f75d202b2b98` == HEAD, `git diff HEAD` empty, marker absent from all 14 built files, whole-tree `git status --porcelain` clean. **B · drop item 2's `true` arm** — plant `ablation18877DropsTrueArm` to restore the pre-ruling door (only `false` moves the registry). Marker present in 2 built files of `@objectstack/runtime`. Predicted red, observed red: exactly 3 pins failed, all of them `true`-arm ones — and one of them is objectstack-ai#18058's own pre-existing `re-installing with enableOnInstall: true clears the durable disable`, which is the measurement showing the new arm is load-bearing rather than decoration. Restored: blob `010a542447e8` == HEAD, `git diff HEAD` empty, marker absent from all 6 built files, tree clean. Both suites green again afterwards at `e40efa41b` (9 + 34 passed), which is the commit every number above was taken at. ## Acceptance notes - **Declared file-surface breach.** The dispatch claim named `packages/objectql/src/` as the file surface. The ruling's items 2 and 3 land in `packages/runtime/src/domains/packages.ts`, and the pins item 4 re-rules live in `packages/runtime/src/domains/packages-install-enable-on-install.test.ts` — so **no arm of this card is implementable inside the declared surface**, including a registry-only slice, which would have left `enableOnInstall: true` silently unhonoured on an existing row. The breach was de-risked first, not assumed: all 24 open PRs were enumerated and **none** touches `packages/objectql/src/registry.ts`, `packages/runtime/src/domains/packages.ts`, `packages/runtime/src/package-state-store.ts` or the `objectstack-ai#18058` pin file. PR objectstack-ai#18319 touches two other test files in the same directory and is file-disjoint. - **H17 rider objectstack-ai#12789 — SKIPPED, deliberately.** The two `[Registry] Collision` warnings in `registry.ts` do quote the package id inconsistently (one double-quoted at the `sys_metadata`-shadow branch, one single-quoted at its sibling), so the defect is real and it is one line. It is skipped for three reasons: objectstack-ai#12789 is `pm:on-hold` and its card was not read, so which spelling it rules canonical is unknown; the text is runtime output of a published package, so changing it grows the changeset story this card was told not to grow; and it is unrelated to the install contract. - **noted, not filed:** `setPackageDisabled` failures are logged at `warn` at all four call sites in this door (three pre-existing, one added by item 3 in the same words). By the degradation-log rule that is arguably a durability seam — the write claims to persist, the response stays a clean 200/201, and the loss surfaces one restart later — but `setPackageDisabled` is deliberately absent from `DURABILITY_CRITICAL_CALLEES`, so escalating it would red all four sites at once and is a vocabulary decision, not this card's. Carrier: the next card that touches this door's durable half. - **noted, not filed:** nothing parses an install request through `PackageInstallRequestSchema` on the serving path — `PackageApiContracts` has no runtime consumer and the handler reads the raw body — so the schema's `.default(true)` is inert there today. That is what makes «absent» visible to the door at all, and it is the mechanism the PM's own assumption 1 predicted by a different route. It is not filed as a defect because objectstack-ai#19273 already owns the declaration half; recorded here so the next reader does not conclude the door is parsing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…s from its displayed scale (objectstack-ai#19442) Fixes objectstack-ai#19320 Clause-②: yes (widening) ⭐ Declared from the **measurement**, ⛔ not from the shape of the change: the accept-set delta over 1950 cells is **36 ADDED / 0 REMOVED**, so the narrowing arm is empty.⚠️ The seat rewrote this line from a backticked, prose-trailing spelling that the repo’s own `readClause2Line` reads as `{kind: near-miss, reason: describing}` — a near-miss is ⛔ not a declaration, and `Check Changeset`’s level axis would have had no input from this body. ✅ **Both halves of the ruling are now here.** The behaviour half (the validator's percent arm) and the `packages/spec` docblock half landed in the same branch; the docblock half needed a file outside the boundary this card was dispatched with, was reported rather than taken, and was then authorized by the dispatching seat. The closing keyword is therefore a closing keyword. ## The ruling this makes live Maintainer ruling batch objectstack-ai#161 item 3 letter B (objectui#9810, comment `5729749935`, 「其他同意」 2026-09-18T12:07Z). Quoted, not translated: > - `packages/spec` `FieldSchema.scale` docblock (and the field reference page): for `percent`, `scale` is the number of decimal places of the percentage-point value as displayed and entered; stored precision follows the storage scale (`fraction` ⇒ `scale + 2` places; `whole` ⇒ `scale`). > - `record-validator.ts` `max_scale` branch: when `def.type === 'percent'` and `percentScaleOf(def) === 'fraction'`, compare against `def.scale + 2`; a pin per storage scale (fraction `scale: 2` accepts `0.1234`, refuses `0.12345`; whole `scale: 2` unchanged). ## Premise re-verification — first-hand, on today's tip, with a lit control The card's premise was second-hand. Both halves were re-measured against `origin/main` at base `24162f95`, through the **real** record validator imported from the built `dist` of `@objectstack/objectql` — no harness, no source shortcut. | case | ruling B requires | measured BEFORE this PR | | --- | --- | --- | | fraction `scale: 2`, write `0.1234` (scale+2 places) | ACCEPT | **REFUSE** `max_scale` `{scale:2, actual:4}` | | fraction `scale: 2`, write `0.123` (scale+1) | ACCEPT | **REFUSE** `max_scale` `{scale:2, actual:3}` | | fraction `scale: 3`, write `0.33333` (the ruling's 33.333%) | ACCEPT | **REFUSE** `max_scale` `{scale:3, actual:5}` | | fraction `scale: 0`, write `0.33` | ACCEPT | **REFUSE** `max_scale` `{scale:0, actual:2}` | | fraction `scale: 2`, write `0.12345` (scale+3) | REFUSE | REFUSE (agrees) | | whole `max: 100, scale: 2`, write `12.34` / `12.345` | ACCEPT / REFUSE | ACCEPT / REFUSE (agrees) | **Lit control, same instrument, same run** — so the refusals above are a reading and not a dead instrument: the validator ACCEPTED `0.12` and `0.5` on the same field, and REFUSED for three *different* reasons — `max_value` on `max: 1` with `5`, `min_value` on `min: 0` with `-0.5`, and `invalid_number` on `'abc'`. `number` / `currency` / `slider` all refused at `scale + 1` in the same run. Docblock half, read at source: `FieldSchema.scale`'s `.describe()` (`packages/spec/src/data/field.zod.ts`) states the 0-100 platform ceiling and nothing about `percent`; `percentScaleOf`'s docblock (`packages/spec/src/data/percent-scale.ts`) states the fraction/whole rule and says nothing about `scale`. **Both halves of the card hold. The premise is TRUE.** ## Accept-set delta — measured in BOTH directions Both predicates (current and ruled) were run over an exhaustive corpus of 1,950 cells: 5 numeric field types x 5 `max` declarations x 6 `scale` values x 13 decimal-place counts. ``` corpus cells: 1950 unchanged: 1914 ADDED (accept set grows): 36 REMOVED (accept set shrinks): 0 declaration classes whose allowance moves: percent max=undefined, percent max=0.5, percent max=1 ``` - The narrowing arm is **empty** — 0 of 1,950 cells. Nothing that writes today stops writing; no stored value is re-read; no migration is implied. - Only fraction-stored `percent` moves. `percent` with `max` above 1, and `number` / `currency` / `slider` / `rating` at every `max`, are byte-identical in verdict. - ⇒ `Clause-②: yes (widening)` is what the measurement supports. It was dispatched as a claim to check; the claim survives the check.⚠️ **One flag for the contract review, not a re-adjudication.** The ruling's own Execution section declares `Clause-②: no`. The mechanical criterion in `pm-dispatch` is 「本卡放宽接受集或扩大公开面吗」, and the accept set is measurably relaxed, so the conservative routing arm is `yes`. The declaration is by design provisional (「按设计临时…⛔ 非终审」), so this is a routing difference to be recorded at review, not a change to the ruling. ## What this PR implements `packages/objectql/src/validation/record-validator.ts` — the `max_scale` branch gains its percent arm. The fraction/whole split is **read from the spec's `percentScaleOf`**, not re-derived from `max` at this seam, so the edit widget, the analytics wire and the validator keep answering from one source. The refusal envelope now reports the allowance that was **applied**: on a fraction-stored `scale: 2` field, `0.12345` is still refused and reports `constraint: { scale: 4, actual: 5 }`. Reporting the raw declaration beside a stored fraction's place count would render "must have at most 2 decimal places (got 5)" on a field that accepts four — a true refusal described by a false constraint. This is the one detail the ruling's letter leaves open; it is decided in the direction that keeps the machine-readable surface honest, and it is pinned. ## The spec half — and the boundary that gated it The ruling's first bullet is the `packages/spec` `FieldSchema.scale` docblock **and the field reference page it generates**. That is `packages/spec/src/data/field.zod.ts`, which was **outside** the four-file boundary this card was dispatched with, so it was reported before being touched rather than taken quietly. The two `packages/spec` paths the dispatch originally named (`numeric-column-representation.ts` and its test) are about the **DDL column** (`numeric_precision` / `numeric_scale`) and mention `percentScaleOf` only inside a prose comment — they are not part of this repair and are untouched. What landed, after the seat authorized the corrected surface: - `packages/spec/src/data/field.zod.ts` — `FieldSchema.scale`'s `.describe()` now states **both** meanings: what the number counts on a `percent` field (decimal places of the displayed percentage-point value) and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒ `scale`, every other numeric type ⇒ `scale`). Both halves go in the **describe**, not only in a source comment, because the reference page is generated from the describe and an author who reads only that page is the author the ruling is about. - `content/docs/references/data/field.mdx`, `data/object.mdx`, `system/migration.mdx` — regenerated by `pnpm --filter @objectstack/spec gen:schema && gen:docs`, ⛔ never hand-edited. **Exactly those three tracked files moved and nothing else**, which is what the pre-commit source/regeneration split was arranged to make legible: the source edits were committed first, so the regeneration commit's file list is the regeneration's own output. - `packages/spec/src/data/percent-scale.ts` — the optional cross-reference, **taken**. The card's own measurement table named *this* docblock as the one silent about `scale`, and `percentScaleOf` is the function the validator calls, so a reader who lands here should find the consequence rather than re-derive it. Written as a pointer, ⛔ not a second copy: the rule is stated once on `FieldSchema.scale` and enforced once in the validator. **Serial constraint re-measured for those paths** before any of it was written, same instrument as the dispatch used: 20 open PRs, `GET /pulls/N/files` each, **0 unreadable file lists**, and no open PR holding any of the eight paths. Lit control on the same run: `packages/spec/src/**` matches 7 open PRs (objectstack-ai#19398, objectstack-ai#19374, objectstack-ai#19373, objectstack-ai#19335, objectstack-ai#19314, objectstack-ai#19090, objectstack-ai#18319), so the zeros are absences the instrument could see. ## Verification **Ablation** — `scripts/ablation-replace.mjs`, anchor `? def.scale + 2` in the production file. -⚠️ The **first attempt was a no-op and its reading is void**: the replacement string was a prefix of the anchor, so its occurrence count could not rise, and the tool refused before running anything. Recorded rather than quietly retried. - Second attempt landed: anchor `x1 -> x0`, blob `2e2d3981d29a -> 7b082941b44f`, command executed, restore proved `blob == HEAD (2e2d398)` with `git diff HEAD` empty. - Under ablation: **8 failed / 100 passed (108)**. All 8 are in the new block and fail for the right reason — the fraction-stored writes are refused with `max_scale`, and the envelope/message read `2` where the ruling requires the applied `4`. - ⭐ **5 of the 13 new cases cannot discriminate, and are not counted as evidence**: the scale+3 refusal, the whole-percent pin, the other-numeric-types controls, the other-refusal-reasons control and the no-declared-scale control pass on both trees **by design** — they are anti-vacuity and lit-control pins, there so that a branch which simply stopped enforcing `scale` on percent fails this block too. **Gate family** — derived from the real changed paths with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, every command run with its exit code captured before any pipe, reconciled with `--ran` carrying the recorded codes: ``` 111 derived · 111 run · 107 green · 0 red · 4 NOT MEASURED · 0 unrun ``` - One real red was found and repaired in this PR: `check:error-code-casing` read the envelope pin's bare `code: 'max_scale'` as an ADR-0112 D1 emission because its recognizer window held no field-addressed neighbour. Naming the field is the repair and a stronger assertion. Re-run exit 0: "no unlisted lowercase error codes in 6371 scanned file(s)". - NOT MEASURED, each with its reason, none of them a red: - `check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: reads built output, 66 packages have no `dist/` in this worktree. - `check:type-check-debt` — exit 3, PREREQUISITE NOT MET: needs the whole workspace closure built. - `check-plugin-teardown-shape.mjs --self-test` — exit 3: its positive-control fixture is pinned to a commit this shallow clone cannot reach. - `check-engine-split-ratio.mjs --days 90` — exit 2: refuses to compute an ADR-0076 D7 ratio on a shallow clone whose oldest visible commit sits inside the window. Counted as "run" by the reconciler (exit 2 is not the prerequisite code) but it measured nothing, so it is reported here as NOT MEASURED. - One gate **refused its prerequisite while spelling it `exit 1`**: `check:skill-examples` reported that `packages/client-react/dist` held no declarations, which is a refusal and ⛔ not a finding. Rather than report it as NOT MEASURED, the package closure was built and the gate re-run to a real verdict: exit 0, **258 prose examples type-check across 3 surfaces**, including the 10 spec-source TSDoc blocks — the surface this PR edits. - The two remaining `exit 3` families were **deliberately left unmeasured**: both need a whole-workspace build, and both are whole-tree families unrelated to a describe string and a validator arm. CI builds everything and measures them there. The `check:skill-examples` closure was built because that gate reads the spec source surface this diff touches — the choice is principled, ⛔ not a budget. - The reconciler's own verdict, DERIVED from the recorded codes rather than claimed: `111 derived famil(ies) accounted for — 108 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)`. **Suites and lint**, at the final commit: - `pnpm --filter @objectstack/objectql test` — **303 files / 5050 tests passed**, exit 0. - `pnpm --filter @objectstack/spec test` — **505 files / 14,752 tests passed**, exit 0; `pnpm --filter @objectstack/spec typecheck` exit 0. - `pnpm --filter @objectstack/objectql typecheck` — exit 0; `check:test-typecheck` OK, ledger unchanged at 40 files / 234 errors / 65 pinned signatures. - `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 15 generated artifacts up to date** against the edited `FieldSchema.scale`. Both gates the ruling's docblock half puts at risk are green by name: **`check:docs`** (the three regenerated reference pages) and **`check:authorable-surface`** (authorable surface + JSON schemas). `authorable-surface.base.json` did not move — a regular build never writes it. - `pnpm lint` — repo-wide `eslint . --no-inline-config`, exit 0 at `bb9f9274`, the final commit. The full union ran; no narrowing was needed, so no narrowing is claimed. - **The ablation reading still describes the shipped file**: `git hash-object packages/objectql/src/validation/record-validator.ts` is `2e2d3981d29a…`, byte-identical to the blob the ablation restored to, so nothing landed on the production file after it was proved able to fail. **Import-side pins**: the public surface of `@objectstack/objectql` is byte-unchanged (no export added, removed or retyped), so only behaviour could move a consumer pin. Every non-`objectql` test file mentioning `'percent'` was checked for a co-occurring `scale`; the six hits are `packages/spec` schema tests and two `service-analytics` wire tests, none of which exercises the record validator. The grep returning six files is its own lit control. ## Acceptance notes Noted, not filed — neither meets the three filing classes, and the carrier for each is named: - The `max_scale` message template (`packages/spec/src/system/validation-message.ts`) reads "must have at most N decimal places", which on a fraction-stored percent now describes the STORED fraction rather than the number the author typed. It is accurate and it is not what the author sees in the widget. Whether a percent-specific sentence is wanted is a display decision that belongs with the ruling's author, not a defect. Carrier: the contract review on this PR. - `packages/spec/src/data/numeric-column-representation.ts` already carries an accurate prose account of the fraction storage rule in its `percent` entry. It is documentation of the column, not of `scale`, and needs no change under this ruling. Carrier: none needed — recorded so the next reader does not re-derive the same dead end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ectstack-ai#19473) Part of objectstack-ai#19417 Clause-②: no (narrowing) ## What landed `POST /api/v1/packages` parses the manifest's `id` leg through `ManifestSchema.shape.id` — the declaration, **by reference** — exactly as the `version` leg beside it has since objectstack-ai#19120. An id `MANIFEST_ID_PATTERN` refuses is answered `400` / `VALIDATION_ERROR` carrying the declaration's OWN sentence, and neither install writer runs. Before: `packages/runtime/src/domains/packages.ts` read the id positionally (`typeof manifest?.id === 'string' ? manifest.id.trim() : ''`) and parsed nothing, so a complete manifest with `id: 'pkg-a'` installed and answered `201` while `defineStack()`, `os build`, `os validate` and the publish face all refused the same id. The author was handed a package that could never be rebuilt or published. Nothing in `packages/spec` moves. `ManifestSchema.shape.id` is already a plain `ZodString` carrying the regex and its custom refusal, so the escape this order named — *"the id leg cannot be gated by reference without a `packages/spec` change"* — is **false**, measured: ```text typeof leg: object ZodString "pkg-a" -> false Invalid package id 'pkg-a' on `manifest.id`. … Did you mean 'com.example.pkg-a'? "com.acme.crm" -> true "com.example.my_erp"-> false … Did you mean 'com.example.my-erp'? ``` ## The two design calls, and what decided them ### 1. Scope — the `id` leg alone Taken as the seat preferred, and for a reason the measurement supplies rather than a preference: each of the remaining residual classes is a separate narrowing of a published wire contract, and this card was graded on the `manifest.id` class. Measured through `HttpDispatcher.handlePackages` **after** this change: ```text 201 1. manifest missing `type` (wrapped) 201 1. manifest missing `type` (bare) 201 2. unknown key, WRAPPED form (dropped) 201 2. unknown key, BARE form (schema refuses by name) 201 3. string-typed `enableOnInstall` 201 3. string-typed body `overwrite` 201 4. install options on the BARE form 400 CONTROL the card class — id `pkg-a` 201 CONTROL a conforming install ``` All seven spellings of the four left-standing classes still answer `201`; the card's class is the only one that moved, and the conforming control still installs. Closing the rest remains the one call this handler pointedly does not make, `PackageInstallBodySchema.safeParse(body)`.⚠️ **One deliberate divergence from the order's pin 5.** The order asks for those four to be **pinned** as `201`, saying objectstack-ai#19120 did that. objectstack-ai#19120 did not: its changeset carries the measurement, and its test file forbids the pin in as many words — *"Pinning them as `201` would freeze four known residuals as intended behaviour and turn the card that closes one of them red for doing its job … The separability evidence lives where a one-shot measurement belongs — the PR body — not in a permanent expectation."* That reasoning is sound and landed, so the substance of pin 5 is delivered here (a measured, checkable scope claim, in this body and in the changeset) and its form is not. The probe that produced the table was a one-shot file, run and deleted in the same round. ### 2. Ordering — the gate sits AFTER the empty-id check, and BEFORE the version gate `''` fails `MANIFEST_ID_PATTERN` too, so placement decides whether a **published message** moves or only the accept set does. Three readings decided it: - **The weaker sentence.** On `''` the refusal's suggestion arm has nothing to offer — it verifies its candidate against the pattern and `com.example.` plus an empty string does not match — so a schema-first gate would replace `Package id is required` with `Invalid package id ''` and no remedy, for a body this door **already refused**. The defect on this card is `201`s, not `400`s. - **A landed pin says so.** `packages-install-manifest-version.test.ts` already pins *"the id gate still wins — no id means no sentence this gate could print"*. Measured: disabling the `!pkgId` check turns that landed case red along with five new ones. A schema-first gate reverses a pin objectstack-ai#19120 wrote deliberately. - **The artifact-path precedent does not reach here.** `DOOR 1 (schema)` in `artifact-granted-permissions.test.ts:237` is the FIRST door on that path, with no published `required` sentence ahead of it to displace. This door has one. Ordered **before** the `version` gate for the mirror-image reason: the version refusal's sentence names the id it is prescribing for, and prescribing a `version` repair for an id that can never be legal sends the author round twice. ⛔ The **raw** value is parsed, not the trimmed `pkgId`: the trim keys the package and must not also launder an id past its own rule, or `' com.acme.crm '` would keep installing a manifest whose stored `id` the declaration refuses. Downstream this makes the trim a no-op by construction on every accepted path. ## The `pkg-a` pin is REVERSED, not deleted `domain-handler-registry.test.ts` drove the duplicate-id guard with `{ id: 'pkg-a', … }`, whose `forced` limb asserts `201` — unreachable for this id once the door parses it, whatever order the gate sits in. The fixture is repaired to `com.example.pkg-a` (the repair `manifestIdRefusal` itself prescribes for `pkg-a`) so that case keeps testing the duplicate guard, and a new case beside it asserts the `pkg-a` refusal. The reading survives, pointing the other way — the same repair objectstack-ai#19120 owed this fixture one key over. ## Verification **Tests** — `pnpm --filter @objectstack/runtime test`: **272 files, 3796 passed, 1 skipped**, at `1736a93b3a`. `pnpm --filter @objectstack/runtime typecheck`: green (test layer included, via `tsconfig.test.json`). **Reverse verification** — two ablations, both through `scripts/ablation-replace.mjs`, each proving the mutation landed on disk (anchor count and `git hash-object` blob) and each proving its own restore (blob equals HEAD, `git diff HEAD` empty). Both ran from the committed fix, and the direction is the one predicted in advance: red. | ablation | what it removes | result | | --- | --- | --- | | `if (!declaredId.success) {` to `… && false) {` | the new gate | blob `0df0cab46a2a` to `4653ea1e6f4c`; **16 failed / 72 passed** — every new pin plus the reversed `pkg-a` one; §0, §2 and §3a stayed green | | `if (!pkgId) {` to `… && false) {` | the empty-id gate ahead of it | blob `0df0cab46a2a` to `6b2a24fbfc8a`; **6 failed / 48 passed** — all five §3a message pins **and** objectstack-ai#19120's landed *"the id gate still wins"* | The second ablation is what makes §3a a lit control rather than an assertion that cannot fail, and it is the direct measurement behind design call 2. **Gates** — `node scripts/pm/dispatch-gates.mjs --commands` derived **60** families from the real change set; reconciled with `--ran` carrying every exit code: **59 run green, 1 NOT MEASURED, 0 unrun.** The one not measured is `pnpm check:dual-build-cjs-loads`, which exits **3** with `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/` (38 packages); it needs a whole-repo `pnpm build`, which is CI's Build Core job. ⛔ Recorded as not measured, not as a pass. `pnpm check:type-check-debt` also exited 3 for the same class on the first pass and was re-run green after building `@objectstack/runtime`. **Repo-wide lint** — `pnpm lint` (`eslint . --no-inline-config`, the repo's only style authority) run **in full** over the whole tree at `1736a93b3a`, exit **0**. No narrowing was needed, so no narrowing is claimed. ## Acceptance notes ⛔ Nothing below is fixed here. - **The residual docblock on `PackageInstallBodySchema` (`packages/spec/src/api/package-api.zod.ts`) has drifted.** It enumerates five classes; class 1 lost its `version` half to objectstack-ai#19120, and class 5 — *"it answers `400` in the OPPOSITE direction, to a whitespace-only `id` this declaration admits"* — stopped being true when objectstack-ai#18319 gave `ManifestSchema.id` the pattern, as the spec's own test at `package-api.test.ts` already records from the other side. Stale prose in a comment, so noted rather than filed. Successor: the next card that closes one of the remaining residual classes — that docblock is the register those cards are graded against, so it is read before it is edited. `packages/spec` is fenced out of this PR, which is the other reason it is not touched here. - **The trim at the door is now provably inert on every accepted path** — the pattern admits no whitespace, so after the gate `pkgId` equals `manifest.id` byte for byte. Left in place (it still decides the `Package id is required` answer for a whitespace-only id) and documented at the site. Dead-ish code, noted only. - **The second seam recorded on this card is NOT closed here** — see below. ## ⭐ Why `Part of` and not a closing keyword — the one thing to read before merging The card carries a **second seam**, added to its thread by the filing seat: `packages/metadata-protocol/src/protocol.ts` builds `dupManifest` with `id: request.targetPackageId` and writes it through `installPackage` with no `ManifestSchema` parse anywhere in that file, while a few lines up it already *assumes* the reverse-domain shape by defaulting the namespace to `targetPackageId.split('.').pop()`. That is the duplicate door, not this HTTP install door, and it is untouched by this PR. The dispatch order asked for a closing keyword. Its re-derived premise, its file surface and all five of its pins are about the install door alone, and it does not mention the second seam anywhere — so the order appears not to have weighed it. A closing keyword here would close the card with a live, explicitly recorded seam still open, and GitHub's parser ignores any prose written to prevent that. `Part of` is the reversible half of that choice: the card stays open, and the seat decides — either file the duplicate-door seam as its own card and then retire objectstack-ai#19417 by hand, or edit this body's first line. ⛔ This is flagged, not decided, and it is the one place this PR departs from its order without the order having left the question open. --- _Generated by [Claude Code](https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf)_ --- ### Seat edit — 2026-09-21T01:50Z Two words rewritten by the `domain:cli` PM seat objectstack-ai#6024, and nothing else: *"…as its own card **and then retire** objectstack-ai#19417 by hand…"*. The previous wording bound a closing keyword to `objectstack-ai#19417` **across a newline**, which GitHub's parser joins — so this body declared `Part of objectstack-ai#19417` and a closing binding on the same number, the shape `check:partof-closing-keyword` RULE 1 refuses, and with `squash_merge_commit_message = PR_BODY` it would have retired the card on merge. ⭐ **`Part of` is kept deliberately.** Verified before editing: the card carries a **second seam of the same class** that this PR does not touch — `packages/metadata-protocol`'s duplicate door, recorded on objectstack-ai#19417 by the filing seat at comment `5751874616`. So objectstack-ai#19417 must survive this merge and be retired by hand once that seam has a card of its own. A closing keyword would have been the irreversible half of that choice. **Receipt for the docs drift check (`5754348208`)** — read in full and swept by hand, ⛔ not deferred: The check named **14** hand-written pages, but most matched on `/packages/:id` *"a path literal in a **comment** on a changed line"* — a comment mentioning a route is not an assertion this diff can falsify. Scoped to the one anchor that can be — `manifest.id`, since this change makes ids the door used to accept start being refused: - **0** pages carry a package-id example that `MANIFEST_ID_PATTERN` refuses. - ⭐ **Lit control**, 3 real package-id examples, all passing: `com.acme.crm` (`api/declarative-endpoints.mdx:50`, `protocol/kernel/http-protocol.mdx:1182`) and `com.example.my-app` (`deployment/cli.mdx:2036`). ⇒ clean negative; nothing filed. The 3 release-owned pages matched via the same comment-literal anchor and are read-only regardless.⚠️ Three impostor hits were caught and discarded on the way, recorded so the next sweep does not trust a count: a first probe matched every `id:` in the corpus (56 "refusals" that were record ids like `acc_123` and nav ids like `nav_accounts`); a tightened context heuristic still surfaced `ui/apps.mdx:538` `id: 'active_package'`, which is a contextSelector's own id and matched only because `valueKey: 'manifest.id'` sits five lines below it. Opening each hit is the only detector. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…d `version` half and open `type` half (objectstack-ai#19935) Fixes objectstack-ai#19327 Clause-②: no `PackageInstallBodySchema`'s docblock in `packages/spec/src/api/package-api.zod.ts` lists the bodies `POST /api/v1/packages` answers `201` to while the declaration refuses them. Its clause 1 recorded "a manifest missing `type` and/or `version`" as ONE class. PR objectstack-ai#19326 made the door parse `ManifestSchema.shape.version` by reference, so only the `type` half of that clause is still true. This PR splits the clause. The diff is the docblock, one spec test file and one changeset. No schema, accept set, export or runtime code moves, and residual classes 2 to 5 are untouched. ## Both halves re-measured at `origin/main` `fdeeea0cc9`, before any edit Both halves read exactly as the card says. | half | body sent to the door | status | `error.code` | installed | |---|---|---|---|---| | control | wrapped, well-formed | 201 | none | yes | | `version` | wrapped, no `version` | **400** | `VALIDATION_ERROR` | no | | `version` | bare, no `version` | **400** | `VALIDATION_ERROR` | no | | `type` | wrapped, no `type` | **201** | none | yes | | `type` | bare, no `type` | **201** | none | yes | **How it was driven.** I used a one-shot probe, deleted right after the reading and never committed. It was a vitest file at `packages/runtime/src/domains/probe-19327-oneshot.test.ts` with the harness of `packages-install-manifest-version.test.ts`: a real `HttpDispatcher`, spied protocol and registry install writers, and `OS_HOME` redirected. It called `handlePackages('', 'POST', body, {}, admin)` once per row and printed status, code and whether either writer was called. It ran in one invocation with the existing drives that the docblock's parenthesis «both door drives above» points at: ``` bash scripts/pm/os-verify-lock.sh -c "pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 src/domains/probe-19327-oneshot.test.ts src/domains/packages-install-manifest-version.test.ts src/domain-handler-registry.test.ts src/package-door-namespace-conflict-code.test.ts" Test Files 4 passed (4) Tests 82 passed (82) os-verify-lock: VERDICT command-exit 0 ``` - **The `type` half, from both existing drives.** Each drive posts a manifest with no `type`, and each asserts that the door answers `201`: - `package-door-namespace-conflict-code.test.ts` posts `{ id, name: id, namespace, version: '1.0.0' }`. - The duplicate-id case in `domain-handler-registry.test.ts` posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }` (`:600`): `409` first, then `201` on `?overwrite=true`. The body `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` has been that file's REVERSED pin since PR objectstack-ai#19473 and is answered `400` (`:622-623`), so it is not a residual. - **The `version` half, from objectstack-ai#19326's door test.** §1 of `packages/runtime/src/domains/packages-install-manifest-version.test.ts` pins `400` + `VALIDATION_ERROR` + neither writer called, on both body forms. It is **cited, not duplicated**. ## What changed **`packages/spec/src/api/package-api.zod.ts` (docblock only)** - **Clause 1 is split:** - **1a, missing `version`:** marked CLOSED, naming PR objectstack-ai#19326 and the door-side pin. - **1b, missing `type`:** still OPEN, answered `201`, with «both door drives above». - The labels 1a/1b match the table in objectstack-ai#19326's own body, so the two records read the same way. - **The count stays true.** "five classes" remains correct because class 1 is still open through its `type` half. The count sentence now says «class 1 in its `type` half only, since PR objectstack-ai#19326». Numbering 1 to 5 is unchanged, so no citation of clauses 2 to 5 moves. - **In-place fix of the drives paragraph (same defect class).** The paragraph above the list quoted the registry drive as `{ id: 'pkg-a', name: 'A' }` and said «the second carries no `version` either». That drive was repaired twice: PR objectstack-ai#19326 gave it a `version`, and PR objectstack-ai#19473 replaced its id `pkg-a`, which `MANIFEST_ID_PATTERN` refuses. At the merge base `fdeeea0cc9` and at `origin/main` it posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`. The paragraph is clause 1b's antecedent, so left alone it would have filed a no-longer-posted body under the open residual. It now quotes the body the drive posts, says the declaration refuses both drives on `type` alone and the door answers both `201`, credits both repairs, and says the door answers the old `pkg-a` body `400`. **`packages/spec/src/api/package-api.test.ts` (the pin of the clause)** - `DOOR_DRIVE_REGISTRY` now transcribes the body the drive posts: `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`. Its comment credits both repairs, and its `it` title drops "no `version`". - The control that rested on the stale id is now «the missing `type` is what decides it, for BOTH drives — the registry drive's old id is refused on its own». It asserts that the registry drive parses once `type` is added, as the conflict drive does. The old `pkg-a` reading is kept, pointed at the old body: `{ ...registryKeysCompleted, id: 'pkg-a' }` is refused on the id alone, and the green parse just above it is its lit control. - The residual list is hoisted to `DOOR_201_RESIDUALS`, and the existing "the door answers 201 to all of them anyway" assertion is unchanged over it. - **New pin:** «✅ clause 1a is CLOSED — every body in the live residual carries a `version` the declaration accepts». It checks each residual's manifest with `ManifestSchema.shape.version`. Its lit control is that `undefined` is refused. It cites the door-side pin rather than repeating it, because this package cannot import the door. **`.changeset/19327-install-door-residual-split.md`: `patch` for `@objectstack/spec`** A changeset is owed, not `skip-changeset`, because `files[]` ships `src/**/*.zod.ts` and the docblock is also emitted into the built declarations. After the build, the new clause text is in `dist/api/index.d.ts` and `dist/api/index.d.mts`, and the old spelling "and/or `version`" hits 0 across `dist` and `src`. The bump is `patch` because it is a text correction in a released package, with no API change. Note that npm `@objectstack/spec@17.4.0` has no `PackageInstallBodySchema` at all: grep 0, with `PackageInstallRequestSchema` = 4 as the control in the same file. The stale clause has therefore not shipped yet. The next release would be the first to carry it. ## Verification, round 1 (head `826e612b39`) **Reverse verification.** The fix was committed first. I then restored the old drive transcription `{ id: 'pkg-a', name: 'A' }` through `scripts/ablation-replace.mjs`, with the anchor counted 1 → 0, the replacement 0 → 1, and the blob `e976bff69a` → `5b952ed89a`. - **Predicted beforehand:** 2 red, 73 green. The two reds were the new 1a pin, plus the objectstack-ai#17534 lit control in "the missing keys are what decide it", which now takes the drive's `version` through the spread. - **Observed:** `Tests 2 failed | 73 passed (75)`, exactly those two. - **Restore:** the blob is back to `e976bff69a`, equal to HEAD, and `git diff HEAD` is empty. Restore was proven by hash, not by exit code. **Spec package** - **Test:** `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` gives `Test Files 527 passed (527)`, `Tests 15504 passed | 1 todo`, exit 0. - **Typecheck:** `pnpm --filter @objectstack/spec run typecheck` exits 0. `check:test-typecheck` reports OK. `tsc -p tsconfig.test.json --listFilesOnly` lists `src/api/package-api.test.ts` (1 hit), and that file has no debt-ledger entry. - **Build:** `turbo run build --filter=@objectstack/spec` exits 0. `git status` was clean afterwards, so no generated artifact moved. **Gates.** From `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at this head: 82 derived, run one by one with each exit code captured before any pipe. `--ran` reports: «✓ dispatch-gates --ran: 82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED (a DERIVED zero — all 82 recorded an exit code and none of them is 3)». - Two gates first answered exit 3 (PREREQUISITE NOT MET) on this fresh worktree: `check:dual-build-cjs-loads` and `check:type-check-debt`. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72 tasks, exit 0), both measured 0: - «✓ check:dual-build-cjs-loads — 104 published require entry point(s) across 67 package(s) load». - «check-type-check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured». **Lint (a narrowed pass, stated as one).** I ran `eslint --no-inline-config --format json` on the three touched paths. The JSON has 3 results. The two `.ts` files show 0 errors and 0 warnings. The `.md` is outside eslint's own population ("File ignored because no matching configuration was supplied"). This narrowing covers everything the full run would: `eslint.config.mjs` never enables type-aware linting (its note at lines 327-328: no `parserOptions.project`, no typed rules), so this diff cannot move the verdict of any untouched file. The full `pnpm lint` union is CI's. **History.** The first commit `b685c67ad2` also rewrote the pending `.changeset/18058-install-door-contract-rebind.md`. `check:empty-changeset`'s foreign-changeset rule refused that with exit 1. The second commit `826e612b39` restores the file byte-identical to the merge base, and the gate reads exit 0 at head. Acceptance note 1 below has the details. ## Patch round: the registry drive, transcribed as it posts (head `0251069c5`) The at-tier contract review of head `826e612b39` (record `5808378321`) failed one item. This PR had transcribed the registry drive as `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` and filed it under the open `201` residual. That drive stopped posting that body at PR objectstack-ai#19473; the body is now the drive file's REVERSED pin, answered `400`. Commit `0251069c5` corrects every copy in one round. The clause split, 1a's CLOSED marking, the count sentence and the `patch` level are unchanged. **Read at both refs before any edit.** `packages/runtime/src/domain-handler-registry.test.ts` and `packages/runtime/src/package-door-namespace-conflict-code.test.ts` are each byte-identical at the merge base `fdeeea0cc9` and at `origin/main` `2c1011b01b`: - The duplicate-id case posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }` (`:600`). It is answered `409`, then `201` on `?overwrite=true` (`:601-604`). - The REVERSED pin posts `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` (`:622`) and asserts `400` (`:623`). - The namespace drive posts `{ id, name: id, namespace, version: '1.0.0' }` (`:83`) and asserts `201` on the first install. **Clause 1b re-judged against both drives.** Both still show it, so 1b stays byte-unchanged. - **Declaration**, from a one-shot `tsx` probe on `src` (not committed): both real bodies fail `ManifestSchema` on `type` alone, and both parse once `type: 'app'` is added. The stale body fails on `id` and `type`. - **Door**, at `0251069c5`: `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 --reporter=verbose src/domain-handler-registry.test.ts src/package-door-namespace-conflict-code.test.ts` gives `Tests 57 passed (57)`. That includes the duplicate-id case (`409`, then `201`), the REVERSED pin (`400`) and namespace section 1 (first install `201`). **What changed** - `package-api.zod.ts`, the drives paragraph only: - It quotes the real body, and says the declaration refuses both bodies on `type` alone while the door answers both `201`. - It credits the `version` repair to PR objectstack-ai#19326 and the id repair to PR objectstack-ai#19473. - It says the door answers the old `pkg-a` body `400`, so that body is not part of the residual. - `package-api.test.ts`: - `DOOR_DRIVE_REGISTRY` holds the real body. - The control that rested on the stale id now asserts that the real body parses once `type` is added. It keeps the old `pkg-a` reading, refused on the id alone. - The clause-1a pin's comment now names the versionless first transcription it meant. - `DOOR_201_RESIDUALS` and its doc comment are unchanged: with the corrected constant, every entry is a body the door answers `201`. - `.changeset/19327-install-door-residual-split.md`: the drives sentence names the body the drive posts, and the `pkg-a` body the door answers `400`. **Reverse verification.** Run after the fix was committed, through `node scripts/ablation-replace.mjs`, which put the stale transcription back. - Mutation: anchor ×1 → ×0, replacement ×0 → ×1, blob `37e99f04ca` → `0331f39bc4`. - Predicted beforehand: 1 red, the renamed control. - Observed: `Tests 1 failed | 74 passed (75)`. The failure is at `package-api.test.ts:945` (`expected false to be true`), the parse of the real body plus `type`. - Restored: the blob is back to `37e99f04ca`, equal to HEAD, and `git diff HEAD` is empty. - Round 1 had no assertion that could go red on this transcription. This head has one. **Verification at `0251069c5`** - **`package-api.test.ts`:** `Tests 75 passed (75)`. On `origin/main` `2c1011b01b` plus this branch's patch it gives `Tests 79 passed (79)`; the patch applies cleanly beside objectstack-ai#19937's changes to the same two files. - **Spec tests:** `--project local` gives `Test Files 527 passed (527)` and `Tests 15504 passed | 1 todo`. The three `repo`-project tests that read `.changeset/` or mention `package-api` give `136 passed`. - **Typecheck:** `pnpm --filter @objectstack/spec run typecheck` exits 0. `package-api.test.ts` is in `tsconfig.test.json`'s program and has no debt entry. - **Build:** the built `dist/api/index.d.ts` and `.d.mts` each carry the real body once and the stale body zero times. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 82. Deriving on `origin/main` plus the patch gives the same 82, with an empty set difference both ways. - `--ran` on the record as captured: «82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)». - The two were `check:dual-build-cjs-loads` and `check:type-check-debt`, with PREREQUISITE NOT MET on a fresh worktree. - After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72 tasks, exit 0), separate re-runs measured both at exit 0: «104 published require entry point(s) across 67 package(s) load» and «4 ledger entr(ies) re-measured … none above its recorded number». - **Lint, a narrowed pass stated as one:** `eslint --no-inline-config --format json` on the three paths gives 3 results. - Both `.ts` files have 0 errors and 0 warnings. The `.md` is outside eslint's population. - `eslint.config.mjs` enables no type-aware linting (its note at `:327-328`), so this diff cannot move any untouched file's verdict. - The full `pnpm lint` is CI's. ## Acceptance notes 1. **The pending release note `.changeset/18058-install-door-contract-rebind.md` restates the same clause.** - It reads «a manifest missing `type` and/or `version` (both of the runtime's own door drives post one)». That has been half false since objectstack-ai#19326. - It will publish into the `@objectstack/spec`, `@objectstack/runtime` and `@objectstack/client` CHANGELOGs at the next release. - It is not edited here. Correcting another PR's pending release note is a release decision that `check:empty-changeset` routes to a person, and that gate stays red on any PR that makes the edit. The proposed replacement sentence goes to the seat in the report. 2. **Clause 5 of the same residual list is stale too.** - It says the door answers `400` to a whitespace-only `id` «this declaration admits». - Since PR objectstack-ai#18319, `ManifestSchema.id` carries `MANIFEST_ID_PATTERN` and the declaration refuses it. `package-api.test.ts` already pins that refusal. - Not touched: the card fences the other classes. Reported to the seat. 3. **The header of `packages/runtime/src/domains/packages-install-manifest-version.test.ts` counts the docblock's five classes differently from the docblock.** - The header counts `version`, `type`, unknown keys, string-typed options and bare-form options. - The docblock has `type`/`version` as one class and the whitespace `id` as class 5. - This predates this PR and is not made false by it. That runtime file is not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #17534 — this lands the ruled change on the authoring contract, the refusal, both scaffolders and the ADR-0087 entry, plus (2026-09-20, under ruling batch #200 item 3) the merge of⚠️ The PR is therefore a GOVERNED diff (
origin/mainand ruling Q3-A component 2: the ADR-0130 D4 record-keeping block.docs/adr/**, tier H, 1 of 95 paths, under the 5000-line line): it lands by the maintainer's own merge or an authorized APPROVED review from aGOVERNED_APPROVERSaccount, after the at-tier contract review.Part of, notFixes: #17534 closes when the seat closes it.Clause-②: yes —
manifest.id's accept set narrows (the reverse-domain pattern is enforced where a bare string was accepted); the carrier of record is on the card (5691010940).What the ruling asked for, and what is here
1. One declaration, referenced from both sites.
MANIFEST_ID_PATTERNis exported frompackages/spec/src/kernel/manifest.zod.ts;ManifestSchema.idandPackageSchema.manifestIdboth reference it.manifest.test.tsholds a per-field verdict table across the two, so a future edit to either has to break a test to drift. The.describe()moves to "must match reverse-domain notation" in the same commit as the regex — #17921's self-contained half, the wording constraint, is satisfied by construction.2. Refusal with a remedy (#4001). The message names the key, echoes the value, shows both documented examples, and prescribes a repair it has already checked against the pattern. Built on the existing
formatSuggestionfromshared/suggestions.zod.ts— no new message builder.The suggestion arm is conditional on purpose: a bare word gets
com.example.NAME; a value that already carries a dot gets its underscores hyphenated in place; a value nothing mechanical rescues gets no suggestion rather than a wrong one. Every candidate is tested against the pattern before it is offered — #17461's lesson applied to the remedy, not only to the examples.The two
@examples were verified first, as the ruling required.com.steedos.crmandorg.apache.supersetboth match. They are no longer a comment:MANIFEST_ID_EXAMPLESis the one list the TSDoc, the refusal and the pin all read, so an example that fails its own rule is now a red test rather than bad advice.3. Both scaffolders derive a conforming id. The bundled template ships
com.example.blank, andcreate-objectstackinterpolatesderiveManifestId(projectName).os initderives from the project name too. The second half prerequisite 4 named is real and is fixed here:os init my-appsanitizes the namespace tomy_appand every template interpolated that namespace into the id, producingcom.example.my_app— refused by this very schema. One project name, two identifiers, contradictory rules; neither can be read off the other. Pinned from both sides against the importedMANIFEST_ID_PATTERN, never a restated regex.4. Migration. ADR-0087 semantic entry
manifest-id-reverse-domain-required— a structured TODO, no automatic rename, because the value IS the identity: the registry addresses a package bymanifest_id, an installed row is keyed on it and a dependent declares it. The changeset carries the matching disposition marker and shipsminorunder the launch-window convention. Sub-question (1) is answered in the entry's ownreasonfrom the TSDoc's words: the rule is "unique across the entire ecosystem", so a package that is never published is still named for the ecosystem it may join.The former blocker — landed, gate green
The 21
packages/qamanifest ids (and, after the maintainer widened the scope on 2026-09-15, the further fixtures acrosspackages/cli,runtime,plugins,spec,objectql,lint,rest) were renamed inside this PR under the 2026-09-15 rulings on #17534; at head1d4c99bcbd(the F1 rounds after the at-tier reviews 5751237820 and 5751666135; round 3 PASS 5751970203) zero literalmanifest.idvalues the pattern refuses remain in the tree outside the declared residues — the install-door residual pinpackages/runtime/src/domain-handler-registry.test.ts:591(pkg-a, kept on purpose: it pins the HTTP install door that still reads the id positionally), this PR's own changeset FROM → TO examples (a migration note must show the refused spelling), and the spec template-manifest negative controls — measured by the round-3 reviewer's eight-leg sweep (brace-key, JSON key, window,manifestId,const manifest, aliased body,id: CONSTANT, factory body, plus an inverse constant leg) over 9070 tracked files through the BUILTManifestSchema.shape.idwith a 12-shape injected control, 12/12 caught (record 5751970203; the dev's own sweeps are on #17534, 5751523635 and the round after it); theDogfood Regression Gate(1/3, 2/3, 3/3) is green on CI. The 2026-09-20 merge had carried 13crm_core/crm_serviceids frommainintopackages/lint/src/validate-translation-references.test.ts, and seven published guidance sites (packages/spec/prompts/create-new-project.md, fivecontent/docspages,service-package/README.md) plus two platform-checklist items and onecreate-objectstackfixture were still teaching refused ids; all renamed in the F1 round (44 occurrences over 10 files). The merge oforigin/main(2026-09-20,scripts/pm/os-regen-merge.sh, merge commit only) forced two pin repairs —packages/spec/src/api/package-api.test.tsandpackages/cli/src/utils/nav-contribution-groups.package-id.test.tslanded onmainwhile this branch was open and measured the accept set this branch narrows; each is rewritten to the measured reality with a lit control,MANIFEST_ID_PATTERNuntouched. The ADR-0130 D4 block records the entry door order (DOOR 1, the schema, refuses an emptymanifest.idasINVALID_ARTIFACT_PACKAGE_ENTRY/ 422; DOOR 2,artifactPackageId, never runs for that spelling), citesMANIFEST_ID_PATTERNandPackageSchema.manifestId, and names the fail-OPEN → fail-CLOSED direction and its population — an addition to D4, not a reversal.In-package fixtures repaired here
The same defect existed inside the packages this card does own, and those are fixed — mechanical, single-literal, and each fixture keeps pinning exactly what it pinned before: three bare ids in
packages/spectests (p,acme), four inpackages/clitests (todo,demo,bad,a/b), and the five golden corpus entries inpackages/cli/src/lint/corpus.ts. That last one is not a fixture: it is the worked-example set the metadata generator imitates, and it was teaching bare-word ids.packages/create-objectstack/vitest.config.tsgains an anchored source alias for the one spec subpath its tests now import, becausepnpm check:test-source-aliascorrectly refuses a pin whose verdict is a function of build state.Not touched, deliberately
docs/adr/0087-metadata-protocol-upgrade-contract.md— the registration this gate wants is the changeset's disposition marker plus the entry file, both present. Editing the ADR would make this a governed-surface diff for no gain. Also untouched: every file in open PR #18198,packages/spec/src/shared/retired-key.ts,packages/spec/src/ui/chart.zod.ts.Verification
Run at
d0d859edbc(the original round). Re-run at the final head6783718d6a(2026-09-20): 137 derived gate families, 137 run, 0 NOT-MEASURED; runtime 270 files / 3734 passed; spec 502 files / 14733 passed, typecheck andcheck:generated(15/15) green; cli unit 221 files / 3127 passed and integration shards 26/207 + 25/220 passed; create-objectstack 16 files / 215 passed;pnpm lintrepo-wide exit 0; ADR anchor/symbol/link gates andcheck-adr-0087-registration --base origin/mainexit 0. The readings of record are in theos-dev-reporton #17534 (5751052645).node scripts/pm/dispatch-gates.mjs --ranpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/cli exec vitest run --project unitpnpm --filter @objectstack/cli typecheckpnpm --filter create-objectstack testpnpm --filter create-objectstack typecheckpnpm lint(repo-wide,eslint . --no-inline-config)pnpm --filter @objectstack/spec check:generatedapi-surface,export-origins,content/docs/referencespackages/qadogfood tierpackages/cli's integration tier is declared to CI: the diff touches no spawn entry, nobin/, notest/helpers/serve-process.tsand no driver or kernel boot path.Original author: Claude Code session
session_01KB5PFtxuy1x3dcR5gxudx6(the implementation rounds); merge, ADR-0130 D4 block and pin repairs by the director seat's round,session_012GcsUbuqFGBibkEDMRC1eE.Generated by Claude Code