Skip to content

spec: retire the CEL expression arms of SLI successCriteria and composite trace-sampling condition - #19084

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-18118-retire-cel-expression-arms
Sep 18, 2026
Merged

os-steve merged 3 commits into
mainfrom
claude/issue-18118-retire-cel-expression-arms

Conversation

@os-steve

@os-steve os-steve commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

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 the
ADR-0087 disposition. ⛔ The needs:contract-review label is the seat's act; this PR neither
hangs 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-retirement playbook.

What was removed

Two union arms, not two keys:

slot was is
ServiceLevelIndicatorSchema.successCriteria z.union([{ threshold, operator, percentile? }, EvaluatedExpressionInputSchema]) the structured object alone
TraceSamplingConfigSchema.composite[].condition z.union([ StructuredFilterRecord, EvaluatedExpressionInputSchema ]) the structured filter record alone

EvaluatedExpressionInputSchema itself is untouched — packages/spec/src/shared/expression.zod.ts
is 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 -n over the whole tracked tree for successCriteria, ServiceLevelIndicator and
TraceSamplingConfig. Every hit outside packages/spec/src is 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/src the readers
are: the two schemas' own unit tests, shared/evaluated-slot-population.test.ts (a census), the
migration registry's prose, and one docblock in shared/evaluated-slot-union.ts. Outside the spec
package the only reader is packages/qa/dogfood/test/expression-conformance.ledger.ts — a
classification 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 this
box but is a different git repository, so no git grep here can see it. It is named rather than
waved at, because the template-title-format row of the same ledger records exactly this limit for
a 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 the
Console Pin Gate enumerate. 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 Gate is path-filtered and was SKIPPED on this very PR; and an
evaluator 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: successCriteria 0 files, ServiceLevelIndicator 0, TraceSamplingConfig 0;
controls in the same run — visibleWhen 340 files, ObjectSchema 652. 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 slis 0; controls — visibleWhen 15, defineStack 47,
@objectstack/spec 269. sampling shows 4 files there and every one was read (an MCP capability
table 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 was
measured, 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
tsc error 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 /** */ comment
was counted as prose, not as a reader — that is why shared/evaluated-slot-union.ts is listed as a
docblock and migrations/registry.ts as prose. Exit codes were captured before any pipe.

The retirement kit

  • The prescription hangs on the surviving schema's own error map, dispatched on issue.input
    (the HookBodyCapability / object.managedBy: 'system' pattern). retiredKey() and an ADR-0087
    D2 strip both retire a KEY; neither retires an ARM, and the keys survive here.
  • Where the prescription reaches, measured on zod 4.4 and pinned both ways. A schema's error
    map is consulted for the top-level invalid_type a NON-OBJECT raises and not for the child issues
    a wrong-shaped OBJECT raises. So on successCriteria the bare-string spelling carries the
    prescription and the { dialect, source } envelope is refused by the structured arm's own
    missing-key issues; on condition both spellings carry it, because the record arm's aborting
    dialect refine sees the object itself. The negative is pinned too: a value refused for a reason
    that is NOT the retirement must not borrow its sentence.
  • ADR-0087 disposition: a D3 SEMANTIC entry, observability-cel-predicates-retired
    (packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts, with
    registry.ts regenerated, never hand-edited between the markers). A predicate is an intent no
    threshold/operator pair or attribute filter records. Both prescriptions therefore carry no
    os migrate meta sentence — owed only where a conversion covers the surface.
  • Changeset with the FROM → TO table and the one-line fix, @objectstack/spec minor with the
    BREAKING banner, per the ruling's Execution section.
  • Baselines and reference pages regenerated, never hand-edited: api-surface-declarations/,
    authorable-defaults/, the two content/docs/references/system/*.mdx pages.

Acceptance notes

  • FOUR published JSON Schemas change projection direction, mechanically (corrected from 「Two」 by at-tier contract review — see below). The retired arm held the
    last .transform() in the system/MetricsConfig and system/TracingConfig subtrees, so both defs
    now project in output mode instead of falling back to the input shape. Consequences, all declared
    in-diff: system/MetricsConfig:slis and system/TracingConfig:sampling publish the default the
    parser has always applied (declared in DEFAULT_CHANGES_BY_MAJOR with the ai/KnowledgeSource:refresh
    row 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 same
    convention 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 config
    on the built package.
  • Consumers swept beyond the named file surface, because they break otherwise. The
    evaluated-slot-population.test.ts census drops 36 positions over 34 declaring lines to 34 over 32,
    naming both departures rather than subtracting them; the evaluated-slot-union.ts docblock drops
    five of 36 to three of 34; the ADR-0058 D7 ledger row cel-declared-unwired-observability closes
    with the removal, and its companion test's inline scan floor drops 3 to 1 with both positions
    named, exactly as that floor's own instruction requires.
  • The ruling's Execution section says Clause-②: no; the dispatch claim comment says
    Clause-②: yes.
    This PR carries the claim's line, because the claim comment is the carrier the
    clause-② check reads and the two must agree. Flagged rather than silently chosen. The yes reading
    also has independent support in this diff: two published JSON Schemas change projection direction.
  • Noted, not filed: the reference pages' inline nested type cells render post-parse optionality
    (a defaulted key reads as required) while the expanded Nested Shape: sections below them read the
    zod 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.
  • Not in this diff, by the ruling: the structured arms (their own card);
    skills/objectstack-formula/SKILL.md, whose structured | cel row for metrics / tracing is the
    skills 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 condition a dialect and a non-blank source — 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-specific
    sweep clause, and routes a hit at either slot to observability-cel-predicates-retired. Verified in the
    GENERATED output, not only the input: registry.ts carries 34 declaring positions once and
    36 declaring positions zero 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 successCriteria missing (the SLI stops parsing) and a composite branch
    with no condition at all.

  • The four defs, named (the two nested ones were disclosed nowhere before): system/MetricsConfig
    (default on slis, plus 8 required members) · system/TracingConfig (default on sampling, plus 4)
    · system/ServiceLevelIndicator (one required member, enabled) · system/TraceSamplingConfig
    (one required member, rules). ⭐ The last two are invisible to the default-changes.ts table by the ratchet's construction, not for lack of a
    default: that table records default VALUES per key, and enabled / rules already carried theirs (true, [])
    published at the base and unmoved here, so no row of it can express a required growth. ⛔ Corrected from an
    earlier 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 default MOVE.


Body edits above made by the domain:spec#4 seat after at-tier contract review; the dev writes the body once, at creation.


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 18, 2026
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/system.txt, packages/spec/authorable-defaults/system.json, packages/spec/dropped-refinements.baseline.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/system.txt, packages/spec/authorable-defaults/system.json, packages/spec/dropped-refinements.baseline.json, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b7eaf6a617b7353825935631f73b5bdcf7b78f90 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0326b87160643cfca37924365c815403bc48b09a — the merge of head 425912a3bb33e1ba5de502e4c0dc7b5ffcdf09b3 into base b7eaf6a617b7353825935631f73b5bdcf7b78f90, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8968c29355f2551e0750608201fb2a87152aa697

Two worktrees: base 176b03582e (the PR's merge base) and head 8968c2935, both fresh detached checkouts with pnpm install --offline --frozen-lockfile (exit 0 each). Instruments: node v22.22.2, pnpm 10.31.0, tsc 6.0.3, zod 4.4.3 (the spec package's own), vitest 4.1.11. ⭐ Every dist read here was produced by the direct package script pnpm --filter @objectstack/spec build in the head worktree — not turbo, no cache. Exit codes captured before every pipe. CI at head: 34 success / 5 skipped / 0 non-green. 17 files; neither skills/objectstack-formula/SKILL.md nor packages/spec/src/shared/expression.zod.ts is among them — both fences held.

(1) Derived judgments

Accept set, measured on the BUILT head package. successCriteria: structured accepted (control); bare string refused with the retirement prescription; the { dialect, source } envelope refused by the structured arm's own child issues, prescription absent; a number refused with zod's own message, prescription absent. condition: { service } and { source } accepted (controls); bare string refused with the prescription; the envelope refused with the prescription (the aborting dialect refine); a number refused with zod's own message. ⇒ the asymmetry the dev pinned is real, and the negative holds. Runtime defaults unchanged. Focused suites: 276 passed.

Reverse verification, three legs against the REBUILT dist/system/index.d.ts. Leg A (retired string spellings): exit 2, two TS2322. Leg B (CONTROL, structured only): exit 0. ⭐ Leg C (envelope spellings, the reviewer's own addition): exit 2 with ONE error — TS2353 on successCriteria only; the condition envelope type-checks against Record and is refused only at parse. Without leg B the two errors would not be a reading.

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 (RecordAlertProps, api.transaction). Radius: tracked files of this checkout at this commit. Measured directly outside it: objectui @ 3e4f6324f7 zero hits, control lit; hotcrm @ 087b7c5dc4 (887 tracked files) zero hits, control lit. Known target still outside: objectstack-ai/cloud (access-denied) and any third-party npm consumer.

⛔ But the dev's "separate positive fact" does NOT close the radius and must not be re-used. export-origins/ records, by its own description, which SOURCE DECLARATION each exported name resolves to — origins, not consumers; the Console Pin Gate is path-filtered and was SKIPPED on this very PR; and an evaluator need not import either symbol — a REST-served metrics config can be read by key. The argument is wrong; the conclusion survives only because the sibling repos were actually measured.

The published JSON Schema — the "second axis", measured. Base→head: exactly four defs differ and all four lose x-io: input — system/MetricsConfig (required gains 8 keys, default appears on slis), system/TracingConfig (gains 4, default on sampling), system/ServiceLevelIndicator (gains enabled), system/TraceSamplingConfig (gains rules). Same-category control system/CacheConfig.json: byte-identical. Convention control at head: of 262 output-mode system defs, defaulted keys are listed required 241 times vs 1 not — the four merely joined the repo's existing convention. Mechanism confirmed: the retired arm held the last .transform() of each subtree.

The four widened consumers — each genuinely FORCED, verified one by one: default-changes.ts (the #4666 block exits 1 on an unauthorised default change; the row IS the gate's prescribed remedy, with a real precedent); dropped-refinements.baseline.json (the emitted site moved, so the #18670 ratchet fails on a site the build no longer observes); the census test + union helper (the two slots are no longer expression positions; the helper keeps two live consumers, not orphaned); the dogfood ledger + floor. ⭐ The floor's own failure message requires naming the departed positions and they are named; ablation (floor 1→3, then restored) fails with exactly that message.

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 error map dispatched on issue.input, the HookBodyCapability / object.managedBy pattern, both verified at their declaration sites. ⚠️ Nuance the dev did not state: both cited precedents ALSO registered D2 conversions because a mechanical rewrite existed — here none does (a stripped predicate leaves an invalid SLI or a silently unconditional sampling branch), so the D3 semantic disposition is right. No os migrate meta sentence: right, per retired-key.ts:42-49.

F1 — BLOCKING. The same unreleased step carries a contradicting D3 entry the playbook requires absorbing. migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts (step 18, landed by PR #18638 at 16:01Z today; protocol is still 17.0.0, so both entries first ship together in step 18) still names "the 36 declaring positions" including these two slots, and still instructs the upgrader that on condition an expression "needs a dialect this platform evaluates AND a non-blank source" — the exact envelope this head now refuses. Playbook §0 「同 major 记账」 requires absorbing an earlier change to the same key in the same unpublished major. The dev applied that principle to the census test (36→34) and the helper docblock (5→3) but not to the published D3 record, so the registry's own text disagrees with the census it cites. Fix: in the earlier entry drop the two positions and the condition-specific sweep instruction, then gen:migration-registry. PR #18985 does not touch that file.

F2 — non-blocking. Changeset, PR body and both default-changes.ts reasons say "Two published JSON Schemas change projection direction". Measured: four; the required growth on the two nested defs is disclosed nowhere.

F3 — non-blocking. The new entry's acceptance proof says authoring a retired spelling "is a tsc error … no longer admits a string or an envelope". Leg C: the condition envelope IS admitted by Record and is caught only at parse. Qualify it.

(2) Semver level

@objectstack/spec minor with the BREAKING banner, FROM→TO table, Clause-②: yes (narrowing), adr-0087 marker. Consistent with the ruling's Execution line and with the launch-window guard (check-changeset-no-major exit 0). The cited 17.5.0 is right at this head.

(3) Boundary flags

  • ⭐ Clause-② no (ruling) vs yes (claim/PR/changeset) — SETTLED BY MEASUREMENT. The "second axis" claim holds and is undercounted: four shipped JSON Schema files, in the npm files, gain required members and two gain default keywords; authorable-defaults/system.json gains two rows. Under contract-review.md's floor rule and lanes/spec.md:20, yes is substantively supported, not merely a safe over-declaration — the seat need NOT correct the carriers to no. For completeness: check-widening-tells --declaration no exits 0 with no tell, so a no would not have been refused mechanically — the measurement, not the tell, is what settles it.
  • Out-of-scope (a), the structured arms equally unread: agree, file as the ruling anticipated.
  • Out-of-scope (b), reference-page nested cells: confirmed pre-existing and repo-wide (241:1), not widened here.
  • The dev's radius argument: answered above — ⛔ do not cite it again as a closure.

Implemented-by: claude/issue-18118-retire-cel-expression-arms
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

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>

Copy link
Copy Markdown
Collaborator Author

Contract review — re-review round

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dc9dacab6f8eebc033d2540a75070130dca79044

Re-review after the patch to the FAIL record on head 8968c2935 (comment 5735594818). Base unchanged 176b03582e. Worktrees: base-176b0358, head-8968c293 (retained) and a fresh head-dc9dacab, pnpm install --offline --frozen-lockfile exit 0. Scope: what moved plus what the patch could disturb.

What moved (git diff --stat 8968c2935..dc9dacab6): 5 files, +141 −81 — the changeset, default-changes.ts, the two step-18 semantic entries, the regenerated registry.ts. Base..head is now 18 files; neither fenced path among them.

⭐ Carry-forward basis, verified by BLOB SHA — not by diff silence. metrics.zod.ts 5d24d484…, tracing.zod.ts 43c78d08…, metrics.test.ts 3890d725…, tracing.test.ts 0308ab21…, expression-conformance.ledger.ts c7a4e634…, expression-conformance.test.ts 3d089bb2… — identical blobs at both heads; and the diff over the whole carry-forward artefact set is empty. So the accept-set readings, the three tsc legs (A exit 2 / B control exit 0 / C exit 2), the runtime defaults and the dogfood ablation stand for this head without re-measurement.

CI at this head: 46 runs = 39 success, 7 skipped, 0 non-green, all completed at read time.

(1) Derived judgments

F1 (was BLOCKING) — RESOLVED, and the absorption is COMPLETE. In the generated output: registry.ts carries 34 declaring positions ×1, 36 declaring positions ×0 (also the 36 , 36 evaluated, 36 positions: zero), lit control observability-cel-predicates-retired ×3. check:migration-registry exit 0. ⭐ Sweep of every step-18 entry file AND the whole step-18 region of registry.ts (lines 5102–12553) for successCriteria, TraceSamplingConfig, composite … condition, needs a dialect, dialect this platform evaluates: the only remaining mentions are the earlier entry's absorption note and its re-routing sentence («deliberately NOT on this sweep … Sweep those two under observability-cel-predicates-retired instead»). No text in step 18 steers an upgrader toward a spelling this head refuses.

F2 — RESOLVED; accounting right, one phrase loose. The FOUR table matches the measured projection exactly (+8 / +4 / +1 / +1 required; default on slis / sampling). ⚠️ The dev's phrase «gain a required member with no default to declare» is TRUE in substance — the #4666 ratchet records default VALUES per key and neither value moved, so it is blind to required growth by construction — but loose in wording: enabled and rules DO carry defaults (true, []), already published at the base and unchanged. The exact form is the changeset's own: «Only the first two carry a default MOVE, so only those two are declarable». The default-changes.ts reasons and the seat's PR-body edit carry the loose form.

F3 — RESOLVED, wording verified verbatim including «⛔ Do not read a clean tsc as a clean sweep of condition», consistent with legs A/B/C.

The D2-precedent nuance is present in the entry's reason and in the changeset, and matches conversions/registry.ts:2268,3686 by declaration site.

⚠️ F4 — NEW, non-blocking: the FOUR paragraph was spliced INTO THE MIDDLE OF A SENTENCE in BOTH default-changes.ts reasons. Rendered from the module at this head: the slis reason reads «…and what he now reads is what the ⚠️ FOUR published JSON Schemas change projection direction in this diff…» and «…not a new one. parser has always applied. To keep the old value…»; the sampling reason similarly. ⭐ The gate prints these reasons on every run that accepts the change — its own text says «reason is printed by every build that accepts the change, so write it for the consumer who is about to be surprised» — so that consumer meets two broken sentences. Not a contract defect, not in the npm files. Fix: move the paragraph to the END of each string and reword «no default to declare» per F2.

Gates re-run at this head: check:migration-registry, check:spec-changes, check:upgrade-guide, check-adr-0087-registration, check-changeset-no-major — all exit 0; check-widening-tells --declaration no: no tell. Tests: --project local over src/migrations + two shared pins → 300 passed; --project repo src/shared/retired-key-migrate-sentence.test.ts → 14 passed.

⭐ Correction to my own 8968c2935 record: that round's «276 passed» was a --project local run, and vitest excluded retired-key-migrate-sentence.test.ts because that pin lives in the repo project — the run said so and I quoted the count without reading the notice. It is now measured, on byte-identical inputs, so it holds for both heads.

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 sampling hits read here too (CHANGELOG:4417 prose, a docs page, an MCP capability-table row, one code comment) — no trace-sampling config. The zero holds on inspection.

⭐ 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 cloud or a third-party consumer cannot be SILENT — the evaluator starves behind an audible refusal and is discovered. What it does NOT cover: a consumer reading stored JSON without the spec parse. With the ruling resting on the domain criterion as well as the zero, that bound is acceptable — but it is a bound.

(2) Semver level

Unchanged and re-verified: @objectstack/spec minor, BREAKING banner, FROM→TO table, Clause-②: yes (narrowing), adr-0087 marker. check-changeset-no-major exit 0. 17.5.0 still right.

(3) Boundary flags

  • Clause-② yes vs the ruling's no: unchanged — the second axis is measured, so yes is substantively supported; the seat need not correct the carriers.
  • Process reading (1) — VERIFIED: spec-changes.json and docs/protocol-upgrade-guide.md mention neither entry id (0 and 0); the builders project up to PROTOCOL_MAJOR 17; both checks exit 0 without regeneration. ⇒ registry.ts is the only published step-18 carrier today — the file F1 was about.
  • Note for the release editor, ⛔ not a finding on this PR: the pending .changeset/15811-evaluated-expression-slots-source-required.md (feat(spec)!: every engine-evaluated expression slot requires a non-blank source #18638's own record, untouched here) still says «36 declaring positions» including both slots and still teaches the condition envelope. It composes into the same release's CHANGELOG beside this retirement's FROM→TO table, which supersedes it. Whoever composes the release notes should read the two together.
  • F4 is the only open item and is non-blocking. If the seat has it fixed before enqueue, the head moves and this record is re-issued — cheap, since only that file would move.

Implemented-by: claude/issue-18118-retire-cel-expression-arms
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

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>

Copy link
Copy Markdown
Collaborator Author

Contract review — re-issue round

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 425912a3bb33e1ba5de502e4c0dc7b5ffcdf09b3

Re-issue after the F4 fix to the PASS record on head dc9dacab6. Base unchanged 176b03582e; 425912a3b^ = dc9dacab6. A fresh worktree head-425912a3 beside the three retained ones, pnpm install --offline --frozen-lockfile exit 0.

What moved (git diff --stat dc9dacab6..425912a3b): exactly one file, packages/spec/scripts/lib/default-changes.ts (+28 −20), and only the two reason strings inside it — key, from, to of both rows unchanged. ⭐ Every other file of the 18 in base..head has an identical blob at both heads (17 of 17 checked by git rev-parse rev:path). Fences: neither fenced path among the 18. ⇒ every reading of the dc9dacab6 record carries forward except F4, and the 8968c2935 readings beneath it carry with it — the inputs of all of them are byte-identical across the three heads.

CI at this head: 35 runs = 33 success, 2 skipped, 0 non-green, all completed.

(1) Derived judgments

F4 — RESOLVED, verified in THREE channels.

(a) Source: the ⚠️ FOUR published mid-sentence 0 · not a new one. parser has always 0 · not a new one. repo who 0 · no default to declare 0 · lit control FOUR published 2.

(b) The module, rendered with tsx and split into sentences: system/MetricsConfig:slis is 11 sentences — sentence 7 ends «…the key, its type and its default are unchanged.» and the FOUR paragraph is sentences 8–11; system/TracingConfig:sampling is 9 sentences, sentence 5 ends the same way, paragraph is 6–9; both strings end «…existing output-mode convention, not a new one.»

(c) ⭐ The channel the finding was actually about — the accepting gate's own printed output. check:authorable-surface at this head (direct package script; anchor = upstream baseline at the merge base; 「📌 2 declared default change(s) accepted (#4666)」; 1541 schemas generated; VERDICT command-exit 0) prints both reasons in full at log lines 1636 (2872 chars) and 1638 (2243 chars): zero splice signatures, zero loose phrase, FOUR published once each, both ending «not a new one.»

F2 precision — now a READING, confirmed off the base blob. authorable-defaults/system.json at 176b03582e: line 206 ServiceLevelIndicator:enabled = true, line 248 TraceSamplingConfig:rules = []; git diff --numstat base..head on that file = 2 0 (two insertions, zero deletions). The new sentence is the exact form.

The other consumers that one file could disturb, re-run at this head under the lock: check:scripts-typecheck (the project that compiles scripts/lib/) && vitest run --project local scripts/authorable-defaults.test.ts → 27 passed, VERDICT command-exit 0 (the && join makes the wrapper certify both). src/ never imports DEFAULT_CHANGES_BY_MAJOR — its six src mentions are comments and registry prose — so no runtime path is reachable from the change.

⭐ 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: check:lean-entry-closure and check:dual-build-cjs-loads are steps of Build Core; check:type-check-debt is the Type Check · debt ledger job. Both records already rested on those jobs' green readings; the local NOT MEASURED lines were an instrument condition of an unbuilt worktree, now partly converted — a second reading agreeing with the first, not a new fact. The remaining NOT MEASURED is measured by Build Core, success at this head; leaving it unconverted rather than holding the shared lock 15+ minutes, and never reaching for OS_SKIP_DTS, is the right call.

(2) Semver level

Unchanged and carried: @objectstack/spec minor, BREAKING banner, FROM→TO table, Clause-②: yes (narrowing), adr-0087 marker — the changeset's blob is identical to dc9dacab6, where check-changeset-no-major and check-adr-0087-registration both exited 0 base..head; Check Changeset success at this head.

(3) Boundary flags

  • Clause-② yes: stands; no carrier line moved; the second axis is measured; no carrier edit owed.
  • Audibility: carried as a BOUND, not a closure. All five owed PR-body edits read back present.
  • Note for the release editor (unchanged, ⛔ not a finding on this PR): the pending .changeset/15811-evaluated-expression-slots-source-required.md still teaches the condition envelope; it is superseded in the same CHANGELOG by this retirement's FROM→TO table.
  • Out-of-scope items (a) and (b) stand unchanged. Nothing else changed at this head; no open findings remain.

Implemented-by: claude/issue-18118-retire-cel-expression-arms
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

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 dc9dacab6 PASS head, so that record's readings and the 8968c2935 readings beneath them carry forward; CI 33/2/0. No open findings.


Generated by Claude Code


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 18, 2026 22:00
@os-steve
os-steve added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit ee5812a Sep 18, 2026
39 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-18118-retire-cel-expression-arms branch September 18, 2026 22:29
os-steve pushed a commit that referenced this pull request Sep 18, 2026
`#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>
os-steve pushed a commit that referenced this pull request Sep 18, 2026
`#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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

2 participants