spec: retire the CEL expression arms of SLI successCriteria and composite trace-sampling condition - #19084
Conversation
…sampling condition
Both slots were z.union([<a structured arm>, EvaluatedExpressionInputSchema]).
The expression arm parsed, normalized a bare string to { dialect: 'cel', source },
registered and was served back, and nothing anywhere evaluated it — ADR-0049
enforce-or-remove. The arms are removed; the structured arms are untouched and
measured on their own card.
The prescription hangs on the surviving schema's own error map (dispatched on
issue.input), because the KEY survives and only one of its two arms went away:
retiredKey() and an ADR-0087 D2 strip both retire a key, neither retires an arm.
The disposition is a D3 semantic entry, observability-cel-predicates-retired, so
neither prescription carries an `os migrate meta` sentence.
Mechanical consequences, all declared: the retired arm held the last transform in
the system/MetricsConfig and system/TracingConfig subtrees, so both defs project
in output mode and publish the defaults the parser always applied
(DEFAULT_CHANGES_BY_MAJOR); dropped-refinements sites move off the union option
path; the ADR-0058 D7 ledger row cel-declared-unwired-observability closes with
the removal and the inline scan floor drops 3 to 1, naming both positions.
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0326b87160643cfca37924365c815403bc48b09a && git checkout 0326b87160643cfca37924365c815403bc48b09a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b7eaf6a617b7353825935631f73b5bdcf7b78f90 425912a3bb33e1ba5de502e4c0dc7b5ffcdf09b3 && git checkout -B drift-repro b7eaf6a617b7353825935631f73b5bdcf7b78f90 && git merge --no-ff 425912a3bb33e1ba5de502e4c0dc7b5ffcdf09b3
node scripts/docs-audit/affected-docs.mjs --json b7eaf6a617b7353825935631f73b5bdcf7b78f90 |
Contract reviewServed-tier: Two worktrees: base (1) Derived judgmentsAccept set, measured on the BUILT head package. Reverse verification, three legs against the REBUILT The card's zero — re-derived with the reviewer's own control, and it HOLDS. Identity scan plus a second pass by property access; same-instrument controls lit ( ⛔ But the dev's "separate positive fact" does NOT close the radius and must not be re-used. The published JSON Schema — the "second axis", measured. Base→head: exactly four defs differ and all four lose The four widened consumers — each genuinely FORCED, verified one by one: The tool choice — RIGHT, by declaration site. A surviving KEY losing one ARM is the class the playbook says none of the three routes fits; the prescription hangs on the schema's own F1 — BLOCKING. The same unreleased step carries a contradicting D3 entry the playbook requires absorbing. F2 — non-blocking. Changeset, PR body and both F3 — non-blocking. The new entry's acceptance proof says authoring a retired spelling "is a (2) Semver level
(3) Boundary flags
Implemented-by: VERDICT: FAIL — one BLOCKING (F1: absorb the same-step D3 entry, one file + registry regen), two non-blocking wording findings to fold into the same patch round. Everything else measured here holds at this head. After the patch the head moves, so the record is re-issued for the new head. Generated by Claude Code Generated by Claude Code |
… defs that change projection direction Contract review F1 (blocking): the evaluated-expression-slots-source-required semantic entry sits in the same unpublished step 18 as this retirement, and still enumerated the two retired slots among "the 36 declaring positions" while telling the upgrader to give a sampling condition a dialect and a non-blank source — the exact envelope this head now refuses. The playbook's same-major absorption rule applies to the published D3 record exactly as it applied to the census test and the helper docblock: that entry now reads 34 positions, names the two absentees and the retirement that took them, and routes a hit at either slot to observability-cel-predicates-retired instead of to its own repair. F2: four published JSON Schemas change projection direction, not two. system/MetricsConfig and system/TracingConfig lose x-io input and gain a default; the nested system/ServiceLevelIndicator and system/TraceSamplingConfig lose x-io input and gain a required member (enabled, rules) with no default to declare, so no ratchet row can hold them — stated in the changeset and in both default-change reasons instead. F3: the new entry's acceptance proof claimed tsc refuses a string or an envelope at both slots. Measured: tsc catches the string at both, and the envelope only at successCriteria; the condition envelope is structurally admitted by the record arm and is refused at parse. The proof now separates the two channels and says which spelling each one catches. Also states the nuance the review asked for: both error-map precedents this retirement copies its mechanism from also registered a D2 conversion because a mechanical rewrite existed, and here none does. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract review — re-review roundServed-tier: Re-review after the patch to the FAIL record on head What moved ( ⭐ Carry-forward basis, verified by BLOB SHA — not by diff silence. CI at this head: 46 runs = 39 success, 7 skipped, 0 non-green, all completed at read time. (1) Derived judgmentsF1 (was BLOCKING) — RESOLVED, and the absorption is COMPLETE. In the generated output: F2 — RESOLVED; accounting right, one phrase loose. The FOUR table matches the measured projection exactly (+8 / +4 / +1 / +1 F3 — RESOLVED, wording verified verbatim including «⛔ Do not read a clean The D2-precedent nuance is present in the entry's
Gates re-run at this head: ⭐ Correction to my own The card's zero — the dev's replacement readings, reproduced. objectui zeros identical; its control counts 340/652 are the with-CHANGELOG figures (318/633 without) — the zero is the same either way. hotcrm controls match exactly; all four ⭐ The audibility argument, judged: it is a BOUND on the cost of a wrong zero, not a closure of it — the PR body's «what covers them» overstates by one word. What it guarantees for any consumer reaching these slots through the spec's parse: an author of either retired spelling is refused with the prescription, and a stored row carrying one fails at the load seam naming the slot. So a wrong zero for (2) Semver levelUnchanged and re-verified: (3) Boundary flags
Implemented-by: VERDICT: PASS — F1 resolved and verified in the generated output and across all of step 18; F2 and F3 resolved; the carry-forward accepted on blob-sha byte-identity; every gate at this head exits 0; CI 39/7/0. One new non-blocking finding (F4) recorded for the seat's disposition. Generated by Claude Code Generated by Claude Code |
…d tighten one phrase Contract review F4: the FOUR paragraph was inserted BEFORE the last clause of each reason string rather than after it, so both reasons rendered with two sentences cut in half — "…what he now reads is what the⚠️ FOUR published JSON Schemas change projection direction…" and "…not a new one. parser has always applied." The gate's own text says the reason is printed by every build that accepts the change and must be written for the consumer who is about to be surprised; that consumer was being handed broken sentences. The paragraph now sits at the end of each string, and both reasons were read back as rendered from the module and from the accepting build's own output. Also tightens the phrase the review found loose. It said the two nested defs gain a required member "with no default to declare". They do carry defaults — enabled is true, rules is [] — and both were already published at the base: measured, not inherited, at authorable-defaults/system.json lines 206 and 248 of the base blob, whose base..head diff is exactly +slis and +sampling, two insertions and no deletions. The wording is now the changeset's own: only the first two carry a default MOVE, so only those two are declarable here, because this ratchet records default VALUES per key and is blind to required growth by construction. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract review — re-issue roundServed-tier: Re-issue after the F4 fix to the PASS record on head What moved ( CI at this head: 35 runs = 33 success, 2 skipped, 0 non-green, all completed. (1) Derived judgmentsF4 — RESOLVED, verified in THREE channels. (a) Source: (b) The module, rendered with tsx and split into sentences: (c) ⭐ The channel the finding was actually about — the accepting gate's own printed output. F2 precision — now a READING, confirmed off the base blob. The other consumers that one file could disturb, re-run at this head under the lock: ⭐ The dev's gate reconciliation (108 run / 1 NOT MEASURED, up from 107/2) — judged: it changes nothing I concluded. The two newly measured gates and the one still unmeasured all live in CI jobs that were green at every head of this card: (2) Semver levelUnchanged and carried: (3) Boundary flags
Implemented-by: VERDICT: PASS — F4 resolved and verified in source, in the rendered module and in the accepting gate's own printed output; the one moved file's every consumer re-run at this head exits 0; all 17 other files carry identical blobs to the Generated by Claude Code Generated by Claude Code |
`#19084` (`ee5812a5e3`) retired the CEL expression arm of `TraceSamplingConfigSchema.composite[].condition` at the very slot this branch projects. Both intents stack: main's side of the slot is taken whole — the record-only `condition` and its retirement prescription — and its `!('dialect' in value)` predicate is declared through this branch's `bannedKeys(['dialect'])` arm. The two renamed ledger rows go, because the arm projects the site they name. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
`#19084` collapsed `TraceSamplingConfig.composite[].condition` to a record, so the ban lands on `condition` itself rather than on a union arm, the ledger rows are spelled `composite.element.condition`, and a CEL envelope is now refused by the runtime too. The live-seam pins and the changeset's accept-set sentence are re-derived on that tree. Also: the changeset declares `Clause-②: yes`, matching the corrected claim and the ruling; and the empty-key-list branch records the real reason it drops — `enum: []` is an invalid schema, not a vacuous rule. Claude-Session: 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>
…rces (objectstack-ai#19137) Part of objectstack-ai#18670 — item 2, the **fourth** of the ruling's four named arms: **banned keys**. This body carries no closing keyword for that number on purpose: measured banned-key sites are still unprojected (§6), and whether the card closes is the seat's call rather than this PR's. Clause-②: yes **Carrier:** the published artefacts `packages/spec/json-schema/system/TraceSamplingConfig.json` and `system/TracingConfig.json`. The published JSON Schema **narrows** toward what the runtime already refuses, and no document the runtime accepts becomes refused. ⭐ **The `yes` stands on the ruling's own axis** — a published artefact narrows — and the at-tier review measured that it stands there **independently of the C5 tell**: `check:api-surface` and `check:api-surface-declarations` both exit 0 with **no diff at all**, because `src/shared/refinement-projection.ts` is re-exported by no entry barrel and is not a `.zod.ts`, so it is not in `files[]`. The C5 widening tell is real — the as-const roster `PROJECTABLE_REFINEMENT_PATTERNS` gains `banned-keys` and an exported `bannedKeys()` appears beside it — but that roster is an **internal** `export const`, not the package's public entry surface. ⛔ The `yes` does not depend on it either way. Director ruling batch objectstack-ai#154 item 3, letter **C** (maintainer 「同意」, 2026-09-18T04:56Z): 「the projection emits a refinement only where the rule is a complete, mechanically derivable JSON Schema pattern — banned keys, required-one-of, non-blank — one ledger row at a time; everything else stays annotated as `x-dropped-refinements`」. --- ## ⛔ This body was REPLACED WHOLESALE by the seat, and last refreshed at 2026-09-19T00:07Z for head `184615ded9` The delivering dev writes a PR body once, at creation, and ⛔ does not patch it; a later correction is named in its report for the seat to write. That convention met a case it does not cover: **the tree the first body described no longer exists.** PR objectstack-ai#19084 (`ee5812a5e3`) retired the CEL expression arm at this very slot before this branch merged `origin/main`, so `condition` is now a plain record and not a union — and the union framing ran through §0, §1, §3 and §4 alike. A patch of some sections would have left the artefact self-contradictory about the only tree it can land on, so the seat replaced it rather than appending a third correction block. Five things were stale, and each is now stated for head `384d27ac18`: | # | was | now | |:---|:---|:---| | 1 | the slot framed as a UNION, the ban emitted into `anyOf[0]` | a RECORD; the ban is conjoined onto it directly (§1, §3) | | 2 | 「objectstack-ai#19005 的普查走到 X 就停了」 — an account of a sibling release being wrong | **RETRACTED.** The candidate set is TIME-DEPENDENT; objectstack-ai#19005 read its own tree correctly (§0) | | 3 | the `$`-ban reaches ONE published node | **THREE**, each measured and named (§6) | | 4 | `77 derived / 74 exit 0 / 3 exit 3` | **82 derived / 78 run, all exit 0 / 4 NOT MEASURED** (§7) | | 5 | a live `Clause-②` disagreement between the claim and the ruling | settled at **`yes`** on both carriers, and the claim comment carries the correction | ⛔ Item 4 and item 5 were the **seat's** errors, not the dev's: the dev copied the claim line verbatim as the dual carrier requires, and only the seat writes claims and labels. Item 2 was the dev's, and the dev retracted it itself on measurement. The retracted text is preserved at the end of this body as HISTORY rather than deleted. --- ## 0. The pre-condition the releasing seat set — and the answer The release of objectstack-ai#19005 set a hard gate on whoever took this card next: > Whoever takes it next must **re-derive the banned-keys candidate set FIRST** and, if it is still empty, **return the card rather than dispatching a dev to find nothing.** **Re-derived. The set is NOT empty, and its clean member is the card's own worked instance.** ⭐ **The candidate set is TIME-DEPENDENT, and that is the whole reason the pre-condition was worth setting.** objectstack-ai#19005's census recorded zero clean candidates, and that was a **correct reading of its own tree** — the `dialect` predicate at this slot did not exist yet; it arrived with objectstack-ai#18638, hours later. The instruction to re-derive the set FIRST is exactly what caught a candidate that landed after the last census, and it is the reason this card had work in it at all. ⛔ No sibling release was wrong; an earlier draft of this body said one was, and that claim is withdrawn. **Instrument:** a TypeScript-AST scan of every `.refine` / `.superRefine` / `.check` call expression under `packages/spec/src/**/*.ts` (non-test), dumping each predicate's argument text — **114 custom-check call sites** across 1008 source files (`superRefine` 69, `refine` 44, `check` 1; 3 `.overwrite` calls excluded, they are not custom checks). LIT CONTROL: 6 of those call sites spell an already-declared arm (`requiredOneOf` ×2, `NON_BLANK_STRING` ×3, `dependentRequired` ×1), so the scan does see the population it is supposed to see. **Radius, by form:** source text of tracked files. **A known target outside it:** whether a given call site's node is a *ledger row* — the ledger's sites are computed at run time by the detector against `packages/spec/json-schema/**`, which is gitignored and returns 0 tracked entries. That is precisely why the earlier shape-only reading on this card was recorded as "not a reading". So the population question was answered with the instrument that can see it: `collectDroppedRefinements` run over the live schemas, plus the generator's own census. **Result — 4 of the 114 predicates judge KEYS at all**, and they split three ways: | call site | predicate | verdict | |:---|:---|:---| | `src/system/tracing.zod.ts` (sampling `condition`) | `!('dialect' in value)` | ⭐ **clean candidate** — a static, self-contained, finite key ban. **2 ledger rows.** | | `src/data/filter.zod.ts:1916` | `!Object.keys(condition).some((key) => key.startsWith('$'))` | an **open** key set — not this arm (§6). Detector verdict `undecidable`, **0 ledger rows**, yet **3 published nodes**. | | `src/ui/action.zod.ts:1844` | `Object.keys(hints).every((k) => known.has(k))` | allowed keys computed from the sibling `data.params` — not mechanically derivable; stays dropped and annotated, exactly as the ruling prescribes. | | `src/data/driver/common.zod.ts:537` | credential leaks at named paths | judges **values**, not key names. Not this pattern. | ## 1. The arm `banned-keys` — "no document may carry any of these keys" — emitted as `propertyNames` with a `not` over the banned names. Same closed-vocabulary mechanism the three landed arms use, no second one introduced: `src/shared/refinement-projection.ts` declares the arm and builds the predicate from that declaration, `scripts/lib/refinement-projection.ts` emits it, and both halves still reach `z.toJSONSchema` through the one shared `projectPublishedJsonSchema` call. **The slot is a record, not a union.** objectstack-ai#19084 retired the CEL expression arm of `TraceSamplingConfigSchema.composite[].condition`, so the node is now a single `z.record(z.string(), z.unknown())` carrying the retirement's own refusal hook and its `abort: true` message. The anonymous `.refine((value) => !('dialect' in value))` that guarded it is replaced by the **declared** `bannedKeys(['dialect'])` — the retirement's prescription, error hook and message are taken from `main` whole, and only the predicate is declared. ⛔ The retirement's behaviour is unchanged by this PR; what changes is that the rule now has a published form. **Exact, not approximate.** A JSON object's properties are exactly its own enumerable string-keyed ones, and `propertyNames` judges exactly those names — so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value: a banned key present with a `null` value is present on both sides. ⛔ **The predicate reads OWN properties and never `key in value`.** `in` walks the prototype chain, so a ban on a name `Object.prototype` carries — `toString`, `constructor`, `valueOf` — would refuse `{}` itself while `propertyNames` accepts it (`'toString' in JSON.parse('{}')` is `true`). That is a disagreement about a JSON **document**, not an edge outside the domain, and it is pinned in both directions. The shipped predicate spells `Object.prototype.hasOwnProperty.call(value, key)` for that reason. **The emitted keywords are conjoined, never substituted.** The node is a record and already states `propertyNames: { type: 'string' }` of its own; replacing it would trade a key-TYPE rule for a key-NAME rule, which is a narrowing paid for with a widening. The ban goes under `allOf`, the same discipline `emitNonBlankString` follows for an existing `pattern`, and the measured `format-type.ts` hazard is untouched — a top-level `anyOf` is still never written, and the reference renderer reads neither `allOf` nor `propertyNames`. **An empty key list emits nothing**, and for a stronger reason than "it would ban nothing": `enum` is specified as a non-empty array, so `{ not: { enum: [] } }` is an **invalid** schema rather than a vacuous one — ajv refuses it with "enum must have non-empty array", which would take the whole published file down instead of leaving a keyword nobody reads. The declaring signature takes a non-empty tuple, so the guard is belt-and-braces at a seam two files apart. ## 2. The rows retired, by name `packages/spec/dropped-refinements.baseline.json`, **202 entries / 553 sites → 200 / 551**: | row | before | after | |:---|:---|:---| | `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — drops nothing now | | `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent |⚠️ Both paths are the **post-retirement** spellings. On the tree this PR was first written against they read `…condition.options[0]`, because the node was then a union arm; objectstack-ai#19084 renamed them by making the node a record, and the rows deleted here are the renamed ones. 2 rows deleted, 0 shrunk, **2 sites closed, 0 sites added anywhere**; the ledger diff is deletions only. Generator census after: **551 dropped across 200 published schemas, 357 projected** — 224 `non-blank-string`, 129 `required-one-of`, 2 `dependent-required`, **2 `banned-keys`** — 9 undecidable. The `measured` block is re-snapshotted from this run: `refinementSitesThatDidProject` 367 → **357** and `refinementSitesWithNoJsonFormToCompare` 3 → **9**. ⛔ **This PR moved neither number.** The projected total fell because objectstack-ai#19084 retired expression arms elsewhere in the tree; the main-tip block was already stale on its own tree. Re-snapshotting is what this PR owes for editing the file at all, and it is not a reading this arm produced. ## 3. The card's own worked instance, before and after The issue body cites `system/TraceSamplingConfig.json`: ``` condition.anyOf[0] = {"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}} ``` — "That accepts `{dialect:'cel'}` — which the **runtime refuses**." The union wrapper is gone with objectstack-ai#19084; the same record is now the node itself, and on the merge base it publishes unchanged in substance: ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } ``` After: ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {}, "allOf": [ { "propertyNames": { "not": { "enum": ["dialect"] } } } ] } ``` and `x-dropped-refinements` is gone from both artefacts. Measured at the slot: `{ "dialect": "cel" }` is refused by the runtime and now by the file; `{ "dialect": "cel", "source": "record.amount > 10" }` is refused by **both** sides —⚠️ that is **objectstack-ai#19084's retirement**, not this PR, and this PR neither revives the expression arm nor extends the refusal; `{ "amount": { "$gt": 10 } }` is accepted by both; `{}` and `{ "service": "api" }` are accepted by both; `{ "dialect": null }` is refused by both. ## 4. Blast radius, measured on the whole published tree Re-measured on the **new** base (`aadea24b89`): the three edited source files were reverted to `origin/main`, the generator re-run, and the two trees compared byte for byte. | reading | value | |:---|:---| | per-schema files common to both trees | 1530 | | **byte-identical** | **1528** | | moved | **2** — `system/TraceSamplingConfig.json`, `system/TracingConfig.json` | The diff of each moved file is exactly: **gain** the `allOf` ban, **lose** the matching `x-dropped-refinements` row. Nothing else in either file changes. (The revert leg was proven on disk — each path's blob hash equalled its `origin/main` blob — and the restore leg by `git diff HEAD` printing nothing.) `openapi.json` was measured **separately and by the right instrument this time**: `gen:schema` never writes it, so the first comparison read two missing files and reported a false MOVED. Running `gen:openapi` on both trees gives a byte-identical file, sha256 `34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa` on both sides. ## 5. Ablation — the pins can fail, both halves Re-run on the **new** head; the earlier ablation measured a tree that no longer exists. `scripts/ablation-replace.mjs` replaced the one line dispatching the arm (`emitBannedKeys(jsonSchema, declared.keys);`) in `scripts/lib/refinement-projection.ts`, with the mutation verified against the disk (anchor 1 → 0, blob `0a21fb6f9b66` → `6e55fe06cef5`): | leg | result | |:---|:---| | `refinement-projection.test.ts` | **exit 1** — 12 failed / 46 passed, including the live seam and the ledger-verdict pin | | `gen:schema` | **exit 1** — naming **both renamed rows** (`composite.element.condition`, `sampling.composite.element.condition`), each record/aborting | | restore | blob back to HEAD, `git diff HEAD` empty | The second leg is the one that matters for the ledger's whole purpose: with the emitter gone, the two deleted rows come **back** as undeclared gaps. The row deletion is load-bearing, not decorative. ## 6. What is left, measured rather than estimated `src/data/filter.zod.ts:1916` bans **every key starting with `$`** on a normalized field condition, and it reaches **THREE** published record nodes in `packages/spec/json-schema/data/NormalizedFilter.json`: - `properties.$and.items.anyOf[0]` - `properties.$or.items.anyOf[0]` - `properties.$not.anyOf[0]` Measured on this head: **all three publish as a bare object** with `propertyNames: { type: 'string' }` and **no ban**, none of them appears in that file's `x-dropped-refinements`, and the file **PASSes a document the runtime refuses** — the runtime's answer for that document names the rule: 「a field condition's keys are field names, never `$`-prefixed operators」. All three read **`undecidable`** to the detector, because `FieldOperatorsSchema` carries `z.date()` members that throw in both io directions — so they hold **0 ledger rows** while the branch-pruning path publishes them anyway. ⭐ **Published yet undecidable is a ratchet blind spot in its own right**, and it deserves a line of its own on the card's worklist, separate from the fifth arm it would take to close. Closing the rule itself is a second public-contract decision, not a refactor of this one: an open key set cannot be spelled as a finite `keys:` list — a list that merely sampled the open set would be WIDER than the rule, which the closed list forbids by construction. It needs a pattern-shaped declaration (`propertyNames: { not: { pattern: "^\\$" } }`). ⇒ closing it is a real narrowing with **no ledger row to make it testable**, which is the opposite trade from this arm. ⭐ The changeset now says the same thing. An earlier revision of it claimed these sites 「stay unprojected and **keep their annotation**」, which is false on the tree; the at-tier review caught the disagreement between the two carriers and the clause was corrected before landing. `src/ui/action.zod.ts:1844` stays dropped and annotated, correctly: its allowed key set is computed from the sibling `data.params`, and JSON Schema cannot express "property names drawn from another array field's values". ## 7. Verification Run on head **`184615ded9`**, each exit code captured **before** any pipe. ⭐ **The at-tier contract review returned PASS**, on head `384d27ac18` (record: PR comment `5737573936`). The branch has moved once since, by exactly one prose clause in one changeset file (`git diff --stat 384d27a 184615d` → `1 file changed, 1 insertion(+), 1 deletion(-)`), so the contract surface the review judged is byte-unchanged and `needs:contract-review` is cleared on both carriers (record: `5737671517`).⚠️ **Any count of this suite is only meaningful beside a statement of whether `packages/spec/dist` was built** — the two readings below are both correct, of different trees: | tree | Test Files | Tests | |:---|:---|:---| | **without** `packages/spec/dist` | `496 passed \| 1 skipped (497)` | `14562 passed \| 1 skipped (14563)` | | **with** `packages/spec/dist` built | `497 passed (497)` | `14564 passed (14564)` | The discriminator is `packages/spec/scripts/root-entry-type-nameability.pin.test.ts`, which takes a **dist-freshness branch at collection time** — ⛔ not a platform check and ⛔ not a bare env var. Not fresh ⇒ it registers exactly one test, `it.skipIf(!EXPECT_BUILT_DIST)(…)`, whose NAME carries the freshness state and the rerun command. Fresh ⇒ it registers two (the declaration-emit pin and its canary). `OS_EXPECT_ROOT_NAMEABILITY=1` does not cause the skip; it only turns the skip into a failure for a lane that expects a built dist. ⇒ `14562 + 1 skipped = 14563`, `14562 + 2 = 14564`. | check | result | |:---|:---| | `pnpm --filter @objectstack/spec test` | **0** — see the two readings above; the count depends on whether `dist` was built | | `pnpm --filter @objectstack/spec typecheck` | **0** | | `pnpm --filter @objectstack/spec build` | **0** | | `pnpm --filter @objectstack/spec gen:schema` | **0** — ledger balanced | | `pnpm --filter @objectstack/spec gen:openapi` | **0** — `openapi.json` byte-identical to base | | `pnpm --filter @objectstack/spec check:generated` | **0** — 16/16 generated artefacts current | | derived gate families (`scripts/pm/dispatch-gates.mjs --ran`) | **82 derived / 78 run, ALL exit 0 / 4 NOT MEASURED / 0 UNRUN** | The four NOT MEASURED are `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` — each exits **3** (`PREREQUISITE NOT MET`, a code that is explicitly neither pass nor failure) because each needs a whole-repo build closure that CI's Build Core / lint.yml produces. They are **declared, not skipped**. ⭐ The earlier count of 77/74/3 was taken **before the changeset file entered the change set**; the five families the changeset brings in (`check-empty-changeset` ×2, `release-rehearsal-clone --self-test`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`) all exit 0. Under-reporting a NOT MEASURED as "tested" is the exact inverse of this lane's reading discipline, and the PR body is where a reviewer reads the coverage claim. `packages/spec` has no workspace dependencies, so the dependency-closure build is empty; the public **entry** surface is unchanged (`src/shared/refinement-projection.ts` is not re-exported from `src/shared/index.ts`, which is why `check:api-surface` and `check:api-surface-declarations` both stay green with no artefact regeneration). ## Acceptance notes - **`dropped-refinements.baseline.json` is a shared hot file.** It is a generated, shrink-only ratchet that every holder regenerates, so a collision resolves by **regenerating** (`scripts/pm/os-regen-merge.sh`), ⛔ never by hand-editing conflict markers. This PR did not wait on it. - **F1 was fixed by MERGING, never rebasing.** `origin/main` was merged into the branch (merge `f66984fb1a`); ⛔ no history on this branch was rewritten. - **Noted, not filed — `scripts/build-schemas.ts:830` still carries a stale mention of the retired `api-surface-signatures.json`.** objectstack-ai#19005's release named the next editor of that file as its carrier. This PR does not edit `build-schemas.ts` at all, so it does not become that carrier. Carrier: the next PR that edits `packages/spec/scripts/build-schemas.ts`. - **Noted, not filed — the `build-openapi.ts` branch still has no live sample.** Another seat measured that all nine schemas it projects read `declaredProjectable=0`. This arm's two sites are not among them, and `openapi.json` is byte-identical across this change. Carrier: whoever next teaches an arm a site that OpenAPI publishes. - **Receipt — Docs Drift Check on this head.** The bot derived 5 anchors from 1 changed package and found **no hand-written page naming any of them**; it also declares that `packages/spec/dropped-refinements.baseline.json` yielded **no anchor**, so pages documenting that file are **NOT COVERED by that run** — explicitly not a clean bill of health. Read and carried here rather than left unanswered: the ledger is a machine-maintained ratchet with no hand-written reference page to drift against, and this PR's edit to it is two row deletions plus a re-snapshot of its own `measured` block.⚠️ It also notes its tree was the MERGE of this head into the base, not the head. - The test file's roster pin previously read "names exactly the two arms this change landed" while listing three; it now reads "the arms this list has landed, and nothing else". --- ## HISTORY — what this body used to say, kept rather than deleted ⛔ Three claims were carried by earlier revisions of this body and are **withdrawn**. They are recorded here because a correction that deletes its own subject is not a correction. 1. **「objectstack-ai#19005 的发布说明写错了,那次普查走到 X 就停了」** — WITHDRAWN and refuted on the trees: the `dialect` predicate was introduced by objectstack-ai#18638, **after** both `5e5ec9fa42` (objectstack-ai#18952) and `72c1640504` (objectstack-ai#19005). At those commits the slot carried zero custom checks and no ledger row, so both zeros were correct readings of their own trees. The correct statement is §0's: the candidate set is time-dependent. 2. **`Clause-②: no`** — WITHDRAWN. The claim comment declared `no`, which is wrong on the ruling's own axis: a published artefact narrows. `check-clause2-carriers` separately judged **C5 广化线索** at `src/shared/refinement-projection.ts` (the as-const `PROJECTABLE_REFINEMENT_PATTERNS` roster gaining `banned-keys`), and the precedent is exact: `required-one-of` (objectstack-ai#18952) and `dependent-required` (objectstack-ai#19005) both shipped `yes` for additions to that same array.⚠️ The at-tier review then measured that roster to be an **internal** export that reaches no entry barrel, so the tell did not have to carry the verdict. Both carriers now declare `yes`, and all three carriers — claim, body, changeset — agree. 3. **`77 derived / 74 exit 0 / 3 exit 3`** — WITHDRAWN, superseded by §7's `82 / 78 / 4`. **Attribution (prose, because the edit side of a PR-body write always appends its own footer):** this body was written by the `domain:spec` PM seat in session `session_01AmH9bKvGoLjiY86Q4Z3og2`; the change itself was implemented by the dispatched dev on branch `claude/issue-18670-banned-keys-projection`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18118
Clause-②: yes — retiring a published authorable surface. Carrier: the changeset
.changeset/18118-retire-observability-cel-arms.md, which declares the same line and theADR-0087 disposition. ⛔ The
needs:contract-reviewlabel is the seat's act; this PR neitherhangs nor clears it.
Ruling: batch #160 item 3, letter A, maintainer 「同意」 2026-09-18T11:59Z — retire the two CEL
expression arms under ADR-0049 enforce-or-remove, by the
spec-property-retirementplaybook.What was removed
Two union arms, not two keys:
ServiceLevelIndicatorSchema.successCriteriaz.union([{ threshold, operator, percentile? }, EvaluatedExpressionInputSchema])TraceSamplingConfigSchema.composite[].conditionz.union([ StructuredFilterRecord, EvaluatedExpressionInputSchema ])EvaluatedExpressionInputSchemaitself is untouched —packages/spec/src/shared/expression.zod.tsis not in this diff at all (it is held by PR #18985, which is not addressed here). What left is two
references to it.
The card's zero, re-derived — and the instrument's radius
Re-derived on this branch's base
176b03582e600ee5628d21bff9422073c5a5530c, not inherited.git grep -nover the whole tracked tree forsuccessCriteria,ServiceLevelIndicatorandTraceSamplingConfig. Every hit outsidepackages/spec/srcis a generated artefact(
api-surface*,authorable-surface*,authorable-defaults,declaration-map,export-origins,json-schema.manifest,dropped-refinements.baseline.json), a reference page,a changelog or changeset, or the shipped skill's prose row. Inside
packages/spec/srcthe readersare: the two schemas' own unit tests,
shared/evaluated-slot-population.test.ts(a census), themigration registry's prose, and one docblock in
shared/evaluated-slot-union.ts. Outside the specpackage the only reader is
packages/qa/dogfood/test/expression-conformance.ledger.ts— aclassification ledger, not an evaluator. No service, plugin, runtime or CLI path reads either key.
Reachable radius of the instrument: tracked files in THIS checkout at THIS commit. It does not
reach untracked or ignored build output, another repository, or a published npm tarball.
One known target outside it: the sibling repository
objectstack-ai/objectui, which is on thisbox but is a different git repository, so no
git grephere can see it. It is named rather thanwaved at, because the
template-title-formatrow of the same ledger records exactly this limit fora different key: an interpolation site that lives there and cannot be measured from here. ⛔ The argument that used to stand here was REJECTED by at-tier contract review and is withdrawn.
It claimed the radius was closed by a positive fact — that nothing in a sibling could evaluate these
slots without importing the symbols naming them, whose consumers
export-origins/and theConsole Pin Gateenumerate. That is wrong on three counts the review measured:export-origins/records by its own description which SOURCE DECLARATION each exported name resolves to — origins,
NOT consumers; the
Console Pin Gateis path-filtered and was SKIPPED on this very PR; and anevaluator need not import either symbol, since a REST-served metrics config can be read by key.
What closes the radius instead is direct measurement outside it, with a lit control in each repo.
objectui @
3e4f6324f7:successCriteria0 files,ServiceLevelIndicator0,TraceSamplingConfig0;controls in the same run —
visibleWhen340 files,ObjectSchema652. hotcrm @087b7c5dc4(887 tracked files; public, served by this session's git proxy — an earlier dispatch's 「unreachable」
was the seat's error): the same three at 0 plus
slis0; controls —visibleWhen15,defineStack47,@objectstack/spec269.samplingshows 4 files there and every one was read (an MCP capabilitytable row, two prose sentences, a CHANGELOG line — no trace-sampling config), so that zero stands on
inspection and not on the count.
Known targets still OUTSIDE the radius, named rather than waved at:
objectstack-ai/cloud(access denied to this session) and any third-party npm consumer of
@objectstack/spec. Neither wasmeasured, by anyone. What BOUNDS the cost of a wrong zero there is the retirement's audibility (a bound, ⛔ not a closure — at-tier review's wording): a surviving predicate is a
tscerror or a parse refusal carrying the prescription, never a silent change.Every symbol was located by its declaration site, and a literal inside a
//or/** */commentwas counted as prose, not as a reader — that is why
shared/evaluated-slot-union.tsis listed as adocblock and
migrations/registry.tsas prose. Exit codes were captured before any pipe.The retirement kit
errormap, dispatched onissue.input(the
HookBodyCapability/object.managedBy: 'system'pattern).retiredKey()and an ADR-0087D2 strip both retire a KEY; neither retires an ARM, and the keys survive here.
errormap is consulted for the top-level
invalid_typea NON-OBJECT raises and not for the child issuesa wrong-shaped OBJECT raises. So on
successCriteriathe bare-string spelling carries theprescription and the
{ dialect, source }envelope is refused by the structured arm's ownmissing-key issues; on
conditionboth spellings carry it, because the record arm's abortingdialectrefine sees the object itself. The negative is pinned too: a value refused for a reasonthat is NOT the retirement must not borrow its sentence.
observability-cel-predicates-retired(
packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts, withregistry.tsregenerated, never hand-edited between the markers). A predicate is an intent nothreshold/operator pair or attribute filter records. Both prescriptions therefore carry no
os migrate metasentence — owed only where a conversion covers the surface.@objectstack/specminor with theBREAKING banner, per the ruling's Execution section.
api-surface-declarations/,authorable-defaults/, the twocontent/docs/references/system/*.mdxpages.Acceptance notes
last
.transform()in thesystem/MetricsConfigandsystem/TracingConfigsubtrees, so both defsnow project in output mode instead of falling back to the input shape. Consequences, all declared
in-diff:
system/MetricsConfig:slisandsystem/TracingConfig:samplingpublish thedefaulttheparser has always applied (declared in
DEFAULT_CHANGES_BY_MAJORwith theai/KnowledgeSource:refreshrow as the precedent, that mechanism run backwards), and the nested type cells of both reference
pages lose the
?from their default-bearing keys — the output-mode signature, and the sameconvention every transform-free def in the repo already publishes under. No runtime default
moves: measured by byte-identity of the untouched
.default(…)and by parsing a minimal configon the built package.
evaluated-slot-population.test.tscensus drops 36 positions over 34 declaring lines to 34 over 32,naming both departures rather than subtracting them; the
evaluated-slot-union.tsdocblock dropsfive of 36 to three of 34; the ADR-0058 D7 ledger row
cel-declared-unwired-observabilitycloseswith the removal, and its companion test's
inlinescan floor drops 3 to 1 with both positionsnamed, exactly as that floor's own instruction requires.
Clause-②: no; the dispatch claim comment saysClause-②: yes. This PR carries the claim's line, because the claim comment is the carrier theclause-② check reads and the two must agree. Flagged rather than silently chosen. The
yesreadingalso has independent support in this diff: two published JSON Schemas change projection direction.
(a defaulted key reads as required) while the expanded
Nested Shape:sections below them read thezod node and say
optional (default: …). The two disagree for every output-mode def in the repo,not only these; it predates this card and this diff does not widen it. Carrier for anyone who picks
it up:
packages/spec/scripts/build-docs.ts.skills/objectstack-formula/SKILL.md, whosestructured | celrow formetrics/tracingis theskills lane's at tier; and
packages/spec/src/shared/expression.zod.ts.Verification
Run under the shared verify lock; the judged line of each is quoted in the report on the card.
Generated by Claude Code
Same-major absorption (added after at-tier contract review found it missing — the round's one BLOCKING finding).
The same unpublished step 18 carried
entries/semantic/18.evaluated-expression-slots-source-required.ts,which still enumerated these two slots among 「the 36 declaring positions」 and still told an upgrader to
give a sampling
conditiona dialect and a non-blanksource— the exact envelope this head refuses.The playbook's same-major rule applies to the published D3 record exactly as it applied to the census test
and the helper docblock. That entry now reads 34 positions, drops the two slots and the
condition-specificsweep clause, and routes a hit at either slot to
observability-cel-predicates-retired. Verified in theGENERATED output, not only the input:
registry.tscarries34 declaring positionsonce and36 declaring positionszero times.Why D3 is right rather than merely available. Both error-map precedents this retirement copies its
MECHANISM from also registered a D2 conversion, because for them a mechanical rewrite existed. Here none
does: a strip leaves a REQUIRED
successCriteriamissing (the SLI stops parsing) and a composite branchwith no condition at all.
The four defs, named (the two nested ones were disclosed nowhere before):
system/MetricsConfig(
defaultonslis, plus 8requiredmembers) ·system/TracingConfig(defaultonsampling, plus 4)·
system/ServiceLevelIndicator(onerequiredmember,enabled) ·system/TraceSamplingConfig(one
requiredmember,rules). ⭐ The last two are invisible to thedefault-changes.tstable by the ratchet's construction, not for lack of adefault: that table records default VALUES per key, and
enabled/rulesalready carried theirs (true,[])published at the base and unmoved here, so no row of it can express a
requiredgrowth. ⛔ Corrected from anearlier wording of mine that said they had 「no default to declare」 — at-tier contract review measured that as
loose; the exact form is the changeset's own: only the first two carry a
defaultMOVE.Body edits above made by the
domain:spec#4seat after at-tier contract review; the dev writes the body once, at creation.Generated by Claude Code