Skip to content

feat(spec)!: every engine-evaluated expression slot requires a non-blank source - #18638

Merged
os-litant merged 20 commits into
mainfrom
claude/issue-15811-evaluated-slot-narrowing
Sep 18, 2026
Merged

os-litant merged 20 commits into
mainfrom
claude/issue-15811-evaluated-slot-narrowing

Conversation

@os-litant

@os-litant os-litant commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #15811

Clause-②: yes

Rework round — the seat's three items, measured on the rework head (⚠️ NOT the current head — see 〈Base catch-up〉 below)

Seat verdict #18638 (comment) (REWORK on a PASSed contract review). Three items, nothing else re-opened.

1. The changeset and the ADR-0087 entry said something this diff makes false

Both claimed the three union-member positions leave their sibling arm untouched. Re-measured here, base 00115a8442 vs head, parsing each value AS MOUNTED through TraceSamplingConfigSchema:

position sibling arm base head
RecordAlertProps.visible z.boolean() true / false accepted identical
ServiceLevelIndicator.successCriteria structured { threshold, operator, percentile? } accepted, including an object carrying a dialect key identical
TraceSamplingConfig.composite[].condition z.record(z.string(), z.unknown()) see below narrowed

At the tracing slot, six shapes the base accepted through that arm alone — measured: the base's ExpressionInputSchema refuses all six, so the record arm was the only thing admitting them — are refused at head:

authored condition base head
{ dialect: 'cel' } accepted refused
{ dialect: 'js', source: 'x' } accepted refused
{ dialect: 'nope', source: 'x' } accepted refused
{ dialect: 'cel', source: 5 } accepted refused
{ dialect: 'cel', source: 'x', meta: { rationale: 5 } } accepted refused
{ dialect: 'zzz', foo: 1 } accepted refused

Control that HITS: a structured filter carrying no dialect key — {}, { service: 'api' }, { attributes: { 'http.route': '/v1/orders' } } — is accepted at base and at head alike. Without it the six refuseds would be a schema that refuses everything.

⭐ The narrowing is correct and load-bearing (it is what makes the ruled change non-inert at that slot) and is not removed. What changed is the description: the changeset now carries the table and its FROM → TO, and the migration entry's surface and acceptanceCriteria both name the wider sweep that slot needs — flag every condition object carrying a dialect key, not only the two spellings. A changeset becomes the CHANGELOG and an ADR-0087 entry becomes the migration ledger; neither may ship a false sentence.

2. The published reference page

.refine() has no JSON Schema projection — measured against zod 4.4.3: z.toJSONSchema returns byte-identical output for the plain record, the refined record and the aborting refined record ({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}). So regenerating alone could never move that TYPE cell, and hand-editing the page is forbidden and would be reverted. The fix is at source: the slot's .describe() now states the rule, and gen:docs republishes it. content/docs/references/system/tracing.mdx now reads:

Condition for this strategy — a structured filter object, or a CEL predicate an engine evaluates. ⚠️ The two are told apart by the dialect key: a structured filter must NOT carry one, and an object that does is judged as an expression — so it needs a dialect this platform evaluates and a non-blank source … { dialect: 'cel', ast: … } with no source is refused here.

⚠️ The type cell still renders that arm as a plain record of string to any, and that is faithful to the JSON Schema this repo publishes — which is itself wider than the zod schema, for every .refine() in the spec, not only this one. Making the page contradict the artifact beside it would be worse. Reported as an out-of-scope finding rather than repaired here.

3. The two unpinned message cells — the fix was in the schema, not only in a test

Measured at the slot, on head as it arrived:

  • { dialect: 'cel', source: '' } → one top-level invalid_union with the bare Invalid input; the published sentence appeared only inside nested arm issues;
  • { dialect: 'js', source: 'x' } → refused with the 「needs a non-blank source」 sentence, which misnames the fault: that value's source is fine, its dialect is not.

Root cause, measured: zod 4.4 reports the ONE arm that did not abort, else invalid_union. The record arm's .refine() was non-aborting, so it was the surviving arm for every expression-shaped refusal here and answered for all of them — and it answered with the other arm's sentence.

⇒ The repair is in the schema, not only in a test: the refine becomes aborting, and its message becomes the arm's own rule (module-local, ⛔ not a new published export). Ablation of the accept set: the refused set is identical with and without abort — both measured over the ten-value corpus above, so this is a message change and not a second narrowing. After it, the slot answers exactly what the other 35 answer, and exactly what the migration entry's own acceptance criteria promise:

authored condition before after
{ dialect: 'cel', source: '' } / ' ' invalid_union @ slot, Invalid input one custom issue @ …condition.source, the published sentence
{ dialect: 'cel', ast: … } custom @ slot, published sentence invalid_union @ slot, published sentence
'' / ' ' (bare) invalid_union @ slot, published sentence unchanged
{ dialect: 'js', source: 'x' } custom @ slot, published source sentence invalid_union @ slot, Invalid input — no longer blames source

Pins, in packages/spec/src/system/tracing.test.ts: the accept set (six refusals + the accepting control), both blank spellings' published sentence and its exact code/path, the ast-only and bare-string cells, the negative (a non-source fault is not answered with the source sentence), and the .describe() the reference page renders. And evaluated-slot-population.test.ts's published-sentence pin now runs all three refused spellings at all 36 positions instead of only the ast-only one — 108 cases, all green. That is what would have caught this slot in the first place.

Not re-opened

⚠️ Clause-② is now yes, re-declared by the seat under ruling A (batch #155 item 3, 5725503887, maintainer 「同意」 2026-09-18T05:13Z). The 28-input strict-subtype measurement is NOT overturned — the conclusion drawn from it is: 「a member replaced on a key line is a change to a published contract … the same review a narrowing owes regardless of the tell」. ⇒ a false tell was never the question; a narrowing owes the at-tier review on its own. minor + BREAKING banner + ADR-0087 disposition stay. printCelAst, the package-internal helper and the 36-position census stay. packages/spec/api-surface/shared.json and export-origins/shared.json are still hash-identical to base (git hash-object: cf260910f1… / 0429ff67a6…), and git diff --stat 00115a8442..HEAD -- packages/spec/api-surface packages/spec/export-origins is empty.

Gates, re-derived on the rework head (⚠️ two figures superseded — see the note under it)

node scripts/pm/dispatch-gates.mjs --commands on the merged head, every exit code recorded as it ran, reconciled with --ran: 110 derived, 104 run, 6 NOT MEASURED, 0 unrun (--ran exit 0). pnpm --filter @objectstack/spec build && test && typecheck green — 486 files / 14016 tests; @objectstack/formula 30 files / 871 tests, typecheck green. check:generated: all 15 artifacts up to date after the origin/main merge and the final rebuild.

⚠️ Two figures in the paragraph above are readings on an EARLIER head. They are pinned here, ⛔ not restated as current and ⛔ not retyped.

  • The 110 derived, 104 run, 6 NOT MEASURED, 0 unrun reconciliation was taken on e892c86e271. The head is now 34a63b9d583. ⛔ It is not re-derived here — read it as the reading it was.
  • all 15 artifacts is superseded. feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes #18971 registered a new generated-artifact family and the registered count is now 16. Measured: the GATED array in packages/spec/scripts/check-generated.ts carries 16 entries at 70407326463d, at e892c86e271 and at 34a63b9d583 alike — with a dark control on a non-existent array name extracting 0 lines — so the 15 predates all three heads rather than describing any of them. The 15 is left above verbatim as the historical reading.
  • Current reading, on 34a63b9d583: check:generated exit 0 — all 16 generated artifacts up to date, working tree clean.

Base catch-up — origin/main merged, four deferred artifacts regenerated

Merged origin/main b14610255101 at ab00016c221, regenerated in 34a63b9d583. The os-regen driver deferred on four routed both-sides paths — api-surface-declarations/automation.txt, data.txt, ui.txt and content/docs/references/ui/component.mdx — and ⭐ a deferral silently keeps ONE side: in the merge commit, main's token reads 0 on all four while the branch's side is intact. Main's side was restored and all four re-derived from the merged tree; on the head each path carries both counts, against a positive control that hits on every blob of every path (⛔ a control that fires on one shard certifies one shard).

The two ⛔ MIXED paths are deliberately not routed to the driver and were hand-resolved. packages/spec/src/migrations/registry.ts: generated regions stripped, the hand-written remainder byte-identical across base, both sides and the merge, line counts exactly additive (17142 + 121 + 74 = 17337), and both sides' migration entries present by id. packages/spec/src/ui/component.zod.ts: additive text merge (3750 + 4 + 45 = 3799), its .superRefine() untouched.

Refinement census over non-test packages/spec/src, counted by occurrence rather than by matching line: .refine( 59 base / 60 branch / 59 main / 60 head; .superRefine( 80 at all four; .check( 4 at all four. ⛔ Nothing deleted, nothing weakened.

Non-zero exits, all declared:

  • six exit 3 · PREREQUISITE NOT MET (check:doc-formula-expressions, check:doc-security-posture, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt) — each refuses an unbuilt workspace closure and says so. NOT MEASURED, not findings;
  • check:skill-examples exit 1 — the same class in exit-1 clothing (packages/client-react/dist holds no .d.ts);
  • check:react-declaration-parity exit 1 — run as CI runs it, MANIFEST="$PWD/sdui.manifest.json" … --strict: 111 spec-only divergences, 1 blocks missing from the registry. Control: the identical command at base 00115a8442 prints the same two numbers, so it is pre-existing and this PR moves neither.

One gate went red on this round's own work and is fixed: check:doc-authoring refused an internal issue id in customer-facing spec text — the (#15811) this round put in the tracing .describe(). Removed, page regenerated, gate green; the same gate at base is green, so the id was the only offender.

node scripts/pm/check-clause2-carriers.mjs --pair 18638 — exit 4, and the dual-carrier row C1 is gone: only C5 remains, with the same two false tells (ui/action.zod.ts:833 T2, ui/component.zod.ts:1595 T1). ⛔ Reported, not acted on; the matcher repair is #18640's.

Rework round authored by the domain:spec execution seat, session session_01LvwGppdonww4zGLWZo5rho.

Decision batch #122 item 2 generalised the evaluated-slot rule: EvaluatedExpressionInputSchema now composes into every slot an engine evaluates, while ExpressionSchema / ExpressionInputSchema stay the persistence contract (source OR ast) by item 2 of the same ruling. An ast-only envelope and a source that is blank after trimming — through the envelope key or the bare-string shorthand — are refused at the door instead of parsing, registering, and faulting at run time.

The population was re-derived, not inherited

The census in the card is six days old and shared/expression.zod.ts moved after the ruling, so the 36 figure was treated as a premise. Re-derived by identity on this branch's base 00115a8442 — a negative lookaround on identifier characters, because the bare substring also fires inside CronExpressionInputSchema, TemplateExpressionInputSchema and EvaluatedExpressionInputSchema, which is the trap that inflated triage's own reading on this card (32 files, five of them Cron-only):

reading count
declaring source lines mounting the schema (non-test, non-comment) 34
of those, file-local alias consts mounting 2 slots each 2
declaring positions 36
lit control — identity hits in the definition file 7
the same file counted by bare SUBSTRING 17
dark control — ZzzNoSuchSchema 0

That 7-versus-17 gap in one file is the trap itself, in miniature. Identical to the measured census (#15811 (comment)), position for position. Two aliases: ui/action.zod.ts ActionConditionInputSchema (mounts visible + disabled) and system/settings-manifest.zod.ts SettingsVisibilityInputSchema (mounts the specifier and manifest visible). Three positions reach the schema as a union member rather than head-of-declaration.

PredicateInputSchema is a plain alias of ExpressionInputSchema with zero slot users; it stays wide with the schema it aliases.

Two defects found while measuring, both fixed here

1. The narrowing was INERT at TraceSamplingConfig.composite[].condition. That slot is z.union([z.record(z.string(), z.unknown()), …]), and a bare record arm accepts { dialect: 'cel', ast } as an ordinary record — so swapping the other arm changed nothing. Measured: after the swap and before this fix the slot still answered success: true on the ast-only envelope, while its 35 siblings answered false. The structured-filter arm now declines an object carrying a dialect key, which is an expression attempt whatever it got wrong. Shipping the swap alone would have been a declared-but-unenforced narrowing.

2. Four positions refused with zod's bare Invalid input. Where the declaration wraps the evaluated schema in a WIDER union — a boolean beside it on action.visible / action.disabled / RecordAlertProps.visible, a structured object beside it on ServiceLevelIndicator.successCriteria — the outer union reports invalid_union at the slot and the inner union's sentence never surfaces. evaluatedExpressionUnionRefusal gives those unions the published sentence. It is deliberately stricter than the inner map it complements: it answers only for a blank string or an object carrying dialect, so a malformed threshold object is not blamed on source. It lives in shared/evaluated-slot-union.ts, package-internal and absent from both barrels, on the union-branch-policy convention: a narrowing PR that grows the published export surface widens on a second axis, so api-surface/ and export-origins/ do not move for it.

Item 3 — the printer path is real, and measured

The ruling asked for the lossless direction 「where the dialect has a printer」 before falling back to a structured TODO. Measured rather than assumed: @marcbachmann/cel-js ships serialize, and cel-engine.ts already uses it for its own scope rewrites. So @objectstack/formula gains printCelAst(ast), the inverse of the existing parseCelToAst, and the migration entry prescribes it by name instead of describing a capability nobody can call.

Measured round-trip, six sources, each re-evaluated on the same scope:

record.amount > 10                              -> identical bytes
record.priority == 'urgent'                     -> record.priority == "urgent"
'org_admin' in current_user.positions           -> "org_admin" in current_user.positions
record.a == 1 && (record.b != 2 || record.c > 3)-> identical bytes
size(record.tags) > 0                           -> identical bytes

Lossless about MEANING, not bytes — the printer re-renders from the parse tree, so quote style normalises. Dark controls, all four throwing rather than inventing a source: {}, null, { type: 'nope' } and a plain string each raise Unknown AST operation. printCelAst converts that into null and additionally requires the printed text to parse back through the platform's own bounded parseCelToAst, so it can never widen what this platform evaluates.

Where the printer answers null, and for every blank source, the ADR-0087 D3 entry evaluated-expression-slots-source-required is the structured TODO — naming the object, the field and the slot, and splitting the judgment by fail policy, because removing a key is safe on the fail-soft half of the population and a silent disclosure on the fail-closed half.

Why this is a D3 entry and not a D2 conversion, now that a printer exists. The conversion layer lives in packages/spec, which is dependency-free by Prime Directive #2 and carries no engine — packages/formula/src/normalize.ts states the same boundary from the other side. A conversion that had to call the CEL printer could not live where conversions live, and one that guessed without a printer would be the platform inventing a predicate.

⚠️ Deviation: graded minor, and the ruling said major

Item 3 ordered a 「major changeset」. scripts/check-changeset-no-major.mjs forbids a major marker during the launch window, because the fixed group versions in lockstep and one major promotes all ~70 packages to a whole-stack major — which is a release act reserved to the maintainer. The guard's own header names the two carriers the convention uses instead, and both are present: the BREAKING banner in the changeset body and the ADR-0087 disposition line. The ruling's substance ships; only the marker differs, and it differs because a repo gate forbids the marker. Flagged rather than chosen silently.

Item 4 — the mechanical acceptance surface

#17630 is closed and its widening is live on this base: discovery in packages/qa/dogfood/test/expression-conformance.test.ts matches a roster name by identity anywhere on a line, attributes it to the field: it mounts, and resolves file-local aliases. Both ExpressionInputSchema and EvaluatedExpressionInputSchema are on that roster, so every one of the 36 positions stays discovered across the swap, the ledger's file:Schema.field cover keys are unchanged, and the SCAN_CONTROLS floors (head 37 / inline 3 / alias 2) are unaffected — the swap changes the identifier, never the syntactic shape. No ledger row's failPolicy moves: the column records what the EVALUATOR does with a bad expression, and no evaluator changed.

Clause-② carrier readings, reported rather than acted on

⚠️ Superseded in part by ruling A (5725503887), and the seat has since acted. C5 below reads three widening tells against a Clause-②: no that no longer stands: the declaration is now yes, so the C5 tell no longer gates this PR and the at-tier review does. C1 (the split dual carrier) is also closed — the seat hung needs:contract-review on BOTH card #15811 and this PR at 2026-09-18T09:52:56Z / 09:52:58Z. ⛔ The matcher was NOT touched and no C-class licence card was opened; ruling A refuses both by name. The readings below are kept unedited as the record of what was measured at the time.

node scripts/pm/check-clause2-carriers.mjs --pair 18638 — exit 4, two rows at the time of writing (re-read on the rework head: C1 has cleared, C5 stands — see the rework section above). ⛔ Neither carrier is touched from here; this is the reading, not a verdict.

  • C1 — the dual carrier is split. needs:contract-review is on card spec: the evaluated-slot rule of #15430 reaches only the flow-node ledger — every other ExpressionInputSchema slot an engine evaluates (formula expression, validation / hook / sharing condition, visibleWhen…) still accepts an ast-only or blank-source envelope #15811 and NOT on this PR. That is the state as found; the seat that owns the gate hangs or clears both sides in one stroke.

  • C5 — three widening tells against Clause-②: no. One was real and is gone: the new published export evaluatedExpressionUnionRefusal in api-surface/shared.json, removed by moving the helper package-internal (above), so the published surface is byte-unchanged by this PR. The remaining two are false, and both for the same reason — the matcher fires on an ADDED LINE that has the shape of a widening, and these two lines were added because an options object was appended to a union that gained no member:

    • ui/action.zod.ts ActionConditionInputSchema — read as T2 「a new member of a closed set」. The union has the same two members before and after; what is new on the line is , { error: … }.
    • ui/component.zod.ts RecordAlertProps.visible — read as T1 「a new key on a Zod object schema」. visible existed before this PR; the line moved for the same options object.

    Per the gate's own instruction a false tell is repaired in the matcher (scripts/pm/check-widening-tells.mjs, with a --self-test case pinning the shape) or filed as its own card. Repairing a scripts/pm/** matcher is outside this card's surface, so it is filed rather than done here — see the report's out_of_scope_findings.

Tests

packages/spec/src/shared/evaluated-slot-population.test.ts is the new pin, in two halves because either alone is a green that proves nothing:

  • structural — no declaring position in packages/spec/src still mounts the persistence schema on a code line, with a lit control (the scan does find the name in the definition file and the barrel), a dark control, and an explicit assertion that the Cron / Template / Evaluated siblings do not leak in as substrings;
  • behavioural — all 36 positions parsed AS MOUNTED, refusing all three refused spellings and carrying the one published sentence, plus an assertion that the table reached exactly 36 positions so a position that stops being reachable reds instead of silently leaving;
  • controls — ExpressionSchema / ExpressionInputSchema / PredicateInputSchema still ACCEPT both shapes, and a healthy predicate still parses at all 36 (the settings pair gets the predicate its own closed grammar accepts).

packages/formula/src/print-cel-ast.test.ts pins the printer's two claims, including seven dark-control inputs.

Three existing pins were rewritten rather than relaxed — each pinned exactly the arm this PR deletes:

Repo census for the migration: zero authored occurrences of either refused spelling outside packages/spec's own refusal fixtures, across packages/, examples/, content/ and skills/, against a lit control that hits. Nothing in this repository needs rewriting.

Acceptance notes

  • PredicateInputSchema (shared/expression.zod.ts) remains a plain value alias of ExpressionInputSchema with zero slot users. Left wide deliberately — it aliases the persistence contract. Noted, not filed; carrier is the ledger's own limit 2, already written up there.
  • celEngine.evaluate on { dialect: 'cel', source: '' } answers with the AST-only message rather than an empty-source one. Message accuracy only; the verdict is correct. Unchanged here, still uncarried.

Authored by the domain:spec execution seat, session session_01LvwGppdonww4zGLWZo5rho, under the dispatch claim #15811 (comment).


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/spec, touching 31 documentable anchor(s). ⚠️ 13 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/api-surface-declarations/ai.txt, packages/spec/api-surface-declarations/api.txt, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d4cb05cbf0a8dda962974533ee634393a66440fa.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 13 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/api-surface-declarations/ai.txt, packages/spec/api-surface-declarations/api.txt, …) — pages documenting those are invisible to this run
  • 9 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.

Coarse fallback — 137 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 d4cb05cbf0a8dda962974533ee634393a66440fa → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 546adee9fd0285407ad26a2a2a30f86c01a75f5e — the merge of head 34a63b9d583c881d4c45d7187206417b0c82c17b into base d4cb05cbf0a8dda962974533ee634393a66440fa, 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 546adee9fd0285407ad26a2a2a30f86c01a75f5e && git checkout 546adee9fd0285407ad26a2a2a30f86c01a75f5e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4cb05cbf0a8dda962974533ee634393a66440fa 34a63b9d583c881d4c45d7187206417b0c82c17b && git checkout -B drift-repro d4cb05cbf0a8dda962974533ee634393a66440fa && git merge --no-ff 34a63b9d583c881d4c45d7187206417b0c82c17b

node scripts/docs-audit/affected-docs.mjs --json d4cb05cbf0a8dda962974533ee634393a66440fa

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d4cb05cbf0a8dda962974533ee634393a66440fa → pass the list as
args.docs, on the commit named under Which tree this was computed on.

A narrowing PR that grows the published export surface widens on a second
axis. `shared/evaluated-slot-union.ts` follows the `union-branch-policy`
convention: reachable inside `@objectstack/spec`, absent from both barrels,
so `api-surface/` and `export-origins/` do not move for it.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Seat verdict: REWORK — the review PASSed and two of its named corrections ship false text

Seat session_01LvwGppdonww4zGLWZo5rho, 2026-09-17T12:1xZ. Review of record: posted alongside this comment, PASS at CONTRACT_REVIEW_TIER on head 950da7d3b9.

⚠️ The PASS is correct and it is not a landing permission — the review says so itself: 「Two corrections are owed inside this PR's own surface before merge」. This seat applied the same standard to #18608 this morning and applies it here: a PR does not land shipping a false statement about its own behaviour.

What the review settled, and it settled it well

⭐ The central question — whether Clause-②: no survives two widening tells — is answered by measurement, not argument: a 28-input corpus shows EvaluatedExpressionInputSchema accepts nothing ExpressionInputSchema refuses, refuses exactly four shapes (blank string, whitespace-only string, ast-only envelope, blank-source envelope), and parses all seven jointly-accepted inputs to byte-identical output. ⇒ strict subtype ⇒ both tells are false, and no is right.

It also gives #18640 its root cause: the instrument's #17618 three-fact spend requires the added member list to be a textual subset of the removed one, and here the member was renamed. ⛔ The gate's prescribed clear is a matcher repair with a self-test case, ⛔ never a false yes — so this PR stays no.

What must change — inside this PR's own surface

  1. ⭐ The changeset and the ADR-0087 migration entry both state something the diff makes false. The changeset says 「on those the boolean / object / record arms are untouched」 and the migration entry's acceptance criteria say 「the boolean or object arm is untouched」. ⚠️ At TraceSamplingConfig.composite[].condition the record arm gained a .refine(), and the review measured the consequence: six shapes that the base accepted through that arm alone — {dialect:'cel'}, {dialect:'js', source:'x'}, {dialect:'nope', source:'x'}, {dialect:'cel', source: 5}, {dialect:'cel', source:'x', meta:{rationale:5}}, {dialect:'zzz', foo:1} — are all refused at head.
    ⇒ The narrowing there is real and load-bearing (it is what makes the ruled change non-inert at that slot, which is the round's best finding). ⛔ But both shipped texts deny it. A changeset becomes the CHANGELOG and an ADR-0087 entry becomes the migration ledger — these are the two carriers the launch-window convention calls 「the only signal there is」 for breaking-ness. They must not be false.
  2. The regenerated tracing.mdx still renders that arm as a plain record of string to any. Regenerate or correct it so the published page matches the schema.
  3. Recommended, and the seat wants it done unless you measure a reason not to: pin the published sentence for the blank-source spelling at the tracing slot. The review found two message-quality cells there and neither is pinned: a blank-source envelope surfaces as a top-level invalid_union with the bare Invalid input (the published sentence appears only in nested arm issues, because the record arm's refine is non-aborting so two arms survive), and {dialect:'js', source:'x'} is refused with the 「needs a non-blank source」 sentence, which misnames the fault. ⛔ Unpinned message cells are how the next round re-breaks them silently.

⛔ Not owed — do not re-open

  • ⛔ Clause-② stays no. Measured strict subtype. ⛔ Never flip it to clear a gate false positive; [finding] check-widening-tells.mjs reports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640 carries the matcher repair.
  • ⛔ minor + BREAKING banner + ADR-0087 stays. The review read check-changeset-no-major.mjs's own header: in the launch window, breaking ships as minor because one major promotes ~70 lockstep packages — a maintainer release act. Both guards pass. The ruling's word 「major」 is faithfully delivered as 「breaking with a semantic migration entry」, and the deviation is declared in three places so the director seat can overrule.
  • ⛔ printCelAst, the package-internal helper, and the 36-position census are all confirmed. api-surface/shared.json and export-origins/shared.json are hash-identical to base — verified by git hash-object.
  • ⛔ The nine non-zero gate exits: no red in disguise. Two exit-3 PREREQUISITE cases reproduced with their documented text; check:cross-package-test-inputs reproduced at base with a dist present; the parity gate's exit 1 without MANIFEST is documented by check-generated itself.

Carried, not folded

The review named two things that are ⛔ not this PR's: RecordAlertProps.visible and PageTabsProps.items[].visibleWhen are enforced only where those Props schemas are parsed (objectui reads record:alert props through its own type, and PageComponentSchema.properties is an opaque record) — pre-existing, already on the ledger's cel-record-alert row, and the ruling is positional. The seat files nothing new for it; the ledger row is the carrier.


Generated by Claude Code

…s messages

The changeset and the ADR-0087 migration entry both said the union-member
positions leave their sibling arm untouched. Measured on this branch's base
`00115a8442` and at head: true for `RecordAlertProps.visible` (boolean) and
`ServiceLevelIndicator.successCriteria` (structured object), FALSE for
`TraceSamplingConfig.composite[].condition`, whose record arm gained a
`.refine()` and refuses six shapes the base accepted through that arm alone.
A changeset becomes the CHANGELOG and a migration entry becomes the migration
ledger, so both now state the narrowing, its FROM -> TO and its control.

The refine becomes aborting. That is about the MESSAGE and never the accept
set (measured identical either way): non-aborting, it was the surviving arm
for every expression-shaped refusal here, so a blank `source` collided with it
and the slot published a bare `Invalid input` while the sentence sat nested,
and `{ dialect: 'js', source: 'x' }` was refused with a sentence about
`source` that misnames its fault. Aborting hands each refusal back to its
owner: one `custom` issue at `source` for a blank `source`, one
`invalid_union` carrying the published sentence for an `ast`-only envelope or
a blank bare string.

Pins: the tracing slot's accept set, both blank spellings' published sentence,
and the negative (a non-`source` fault is not blamed on `source`); and the
population pin now covers all three refused spellings at all 36 positions
instead of only the `ast`-only one.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…nce page

`gen:migration-registry` picks up the corrected surface / acceptance text, and
`gen:docs` republishes `TraceSamplingConfig.composite[].condition` with the
`dialect` rule its schema enforces.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…ribe

`check:doc-authoring` flags an internal issue id in customer-facing spec text
(maintainer ruling 2026-08-12). The rule the describe publishes is unchanged;
only the trailing reference is gone, and the reference page is regenerated.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 950da7d3b955ef27b4e252d9c358c8c6599ed7ca

① Derived judgments

  1. THE CENTRAL QUESTION — both tells are FALSE, measured not argued. EvaluatedExpressionInputSchema is a strict subtype of ExpressionInputSchema: over 28 inputs it accepts nothing the persistence schema refuses, refuses exactly four shapes the persistence schema accepts (blank bare string, whitespace-only string, ast-only envelope, blank-source envelope), and parses every jointly-accepted input to identical output. Its string arm (non-empty after trim) is a subset of min(1); its envelope arm is ExpressionSchema.safeExtend with source required and non-blank, so nothing new is admitted (dialect, ast, meta and unknown keys behave identically). T2 at action.zod.ts:833: ActionConditionInputSchema has two members before and two after — z.boolean() and the expression arm; the second argument { error: … } is a zod params object, not a member; base-versus-head accept table differs only in the four refused spellings, true/false survive. T1 at component.zod.ts:1595: visible exists at base component.zod.ts:1594 with the same two arms; same before/after table. The instrument fires because its [finding] check-clause2-carriers T1 reads a function PARAMETER annotated ctx: z.RefinementCtx as a new authorable key — so every PR that adds an object-level refusal raises a widening tell for the refusal itself #17618 three-fact spend needs the added member list to be a textual subset of the removed one, and the member was renamed; its own self-test lists the the widening refusal offers a remedy with no reader — "explain in the claim" moves no exit code (the T1 re-declared-key half did NOT reproduce) #17848 decline for a re-declared key with a zod error param, which cannot apply here for that reason. The gate's prescribed clear is a matcher repair with a self-test case or a filed card — never a false yes. Clause-②: no is right.
  2. Accept-set change, itemised: at all 36 declaring positions the four shapes above leave the accept set; nothing enters. Published TypeScript input types at those positions narrow (source required); the published interface RowCrudPredicates narrows with its two slots; api-surface tracks names only, so it does not move — correctly. The 18 regenerated content/docs/references/** pages change ONLY source?: string to source: string (every changed line, inverse grep zero) — faithful to EvaluatedExpressionSchema; dialect stays the three-value enum in both schema and doc.
  3. ONE EXTRA NARROWING THE PR TEXT MISSTATES. At TraceSamplingConfig.composite[].condition the record arm gained .refine(value has no dialect key). Measured: base accepted {dialect:'cel'}, {dialect:'js', source:'x'}, {dialect:'nope', source:'x'}, {dialect:'cel', source: 5}, {dialect:'cel', source:'x', meta:{rationale:5}}, {dialect:'zzz', foo:1} through the record arm alone; head refuses all six. This is what makes the ruled narrowing non-inert there (the base column proves the record arm swallowed the ast-only envelope regardless of the other arm, so the fix is load-bearing), and it is direction-correct, but the changeset says "on those the boolean / object / record arms are untouched", the ADR-0087 entry's acceptance criteria say "the boolean or object arm is untouched", and the regenerated tracing.mdx still renders the arm as a plain record of string to any. Text correction owed in the changeset and the migration entry; the slot is unevaluated (zero consumers, per census and ledger), so the blast radius is nil.
  4. No other position has a permissive sibling arm: the metrics object arm requires threshold and operator (ast-only refused, measured), the two boolean arms cannot take an object, the settings pair is a superRefine over the evaluated union, and the remaining 30 positions are direct mounts. The behavioural pin exercises all 36 as mounted; my probe confirms the five wrapped ones.
  5. Item 2 honoured: the diff to shared/expression.zod.ts is docblock-only; ExpressionSchema, ExpressionInputSchema and PredicateInputSchema accept the ast-only and blank-source envelopes at head (measured; the population test's control asserts the same).
  6. 36 by identity — verified independently, position for position, with the dev's exact controls (17 versus 7, dark 0); the dogfood ratchet discovers the same 44 keys before and after with its mechanism floors intact.
  7. Message-quality gaps at the tracing slot only: the blank-source envelope surfaces as a top-level invalid_union with the bare message Invalid input (the published sentence is present only in the nested arm issues), because the record arm's refine is non-aborting so two arms survive and evaluatedExpressionUnionRefusal answers undefined for a string source; and an object like {dialect:'js', source:'x'} is refused there with the "needs a non-blank source" sentence, which misnames the fault. The population pin asserts the published sentence for the ast-only shape only, so neither cell is pinned. Follow-up, not a contract defect.
  8. Two of the 36 (RecordAlertProps.visible, PageTabsProps.items[].visibleWhen) are enforced only where those Props schemas are parsed: PageComponentSchema.properties is an opaque record and objectui reads record:alert props through its own RecordAlertRendererProps, not the spec schema. Pre-existing and already recorded on the ledger's cel-record-alert row; the ruling is positional, so not this PR's defect, but the narrowing is declared-only on the raw page path.

② Semver level

minor + BREAKING banner + ADR-0087 disposition is right; a major is not owed. check-changeset-no-major.mjs's header states the launch-window convention verbatim — breaking changes ship as minor while the fixed group versions in lockstep, one major promotes all ~70 packages, and "the mandatory information carriers for breaking-ness in the meantime are the BREAKING banner … and the ADR-0087 migration-ledger disposition … they are the only signal there is". Both carriers are present and both guards pass on the PR range (no-major exit 0; ADR-0087 registration exit 0, entry new in step 18, which spec-changes.json correctly does not yet project since the protocol is 17.x). The ruling's word major is faithfully delivered as "breaking with a semantic migration entry"; the literal marker would be refused by CI and is a maintainer release act. The deviation is stated in the PR body, the changeset and the report, so the director seat can overrule if a whole-stack major was literally intended. @objectstack/formula: minor is right for the additive printCelAst.

③ Boundary flags

  • Deviation major to minor: faithful to the convention's intent, not an evasion (above).
  • Deviation printCelAst in @objectstack/formula: in scope — ruling item 3 prescribes the lossless direction "where the dialect has a printer", which only exists if the printer is callable; cel-js serialize is already a dependency; 15 tests pass including seven dark controls and the re-parse guard. Note: formula has no api-surface baseline, so this new public export is ungated by the tells instrument; the changeset grades it minor, which is the correct level for it.
  • Deviation helper made package-internal: verified — shared/evaluated-slot-union.ts is imported by four zod files and appears in neither barrel; union-branch-policy.ts is the same internal-shared precedent; api-surface/shared.json and export-origins/shared.json are hash-identical to base and origin/main and no other shard moved.
  • Widening-tells false positives (out_of_scope_findings item 1): confirmed false by measurement; repair belongs in scripts/pm/check-widening-tells.mjs with a self-test case for a renamed-to-subtype member beside a zod error param — outside this card's surface, so it must be carried by its own card and escalated to the gate owner; this PR must not flip to yes.
  • Third tell real and gone: verified by the byte-identical surfaces.
  • PredicateInputSchema left wide: verified zero slot users; it aliases the persistence contract; correct under item 2.
  • celEngine.evaluate empty-string message: not measured here; message-only; stays uncarried as reported.
  • dispatch-gates.mjs omitting MANIFEST: consistent with check-generated's own text that the gate exits 1 without a manifest by design; a reading defect, not a gate defect.
  • Gates claim (111/102/9): the nine non-zero exits are the classes the dev names — exit 3 PREREQUISITE reproduced on two of six with the documented text; check:skill-examples reads dist .d.ts through dist-freshness.ts; cross-package exit 1 reproduced at base with a dist present (pre-existing); parity exit 1 without MANIFEST is documented. No red in disguise found. Full spec suite, tsc, formula, lint and metadata-core pass here; the two failing script-tier files fail identically at base for an environmental tsx path.
  • Escalation: none required for the verdict. Two corrections are owed inside this PR's own surface before merge: the changeset and migration-entry sentence claiming the tracing record arm is untouched, and (recommended) pinning the published sentence for the blank-source spelling so the tracing cell is measured.

Implemented-by: claude/issue-15811-evaluated-slot-narrowing
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


⚠️ Posted late, and that is a seat error. The REWORK verdict (5714311656) said this record was 「posted alongside this comment」. It was not — the seat wrote the verdict and skipped the record. The patch round caught it, searched for it, found PR #18638 carrying 0 reviews and no CONTRACT_REVIEW_TIER text anywhere in the repo, and recorded it as NOT MEASURED with the reason rather than assuming 「no flags」. That is the right handling of a missing input and it is worth more than the omission cost. The record above is the one the verdict rested on, posted verbatim and unedited; ⛔ it binds head 950da7d3b9 and does not bind the patch round's new head.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

契约复核记录 — 交付后复核(delta 轮),档位 CONTRACT_REVIEW_TIER

由 domain:spec 席位(session_01LvwGppdonww4zGLWZo5rho)转录落档。本席服务档读数在 CONTRACT_REVIEW_TIER 之下,故复核走转录核验的隔离子代理跑在该档,⛔ 不在席内判、⛔ 不作额度降档。以下为复核代理产出,逐字落档,⛔ 未经本席编辑。

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 62902436d958597c1858dc34a249cf8d8bc7e5bc

① Derived judgments

  1. THE abort: true QUESTION — a message change, not a narrowing, measured. Over a 48-value corpus parsed as mounted (the ten named shapes plus {dialect:undefined}, {dialect:null}, {dialect:'cel',source:null}, {dialect:'cel',source:'\n'}, {dialect:'CEL',source:'x'}, '\n\t', ' x ', null, numbers, booleans, arrays, a Date, a function, an inherited-prototype dialect, a null-prototype object and a Symbol key), the accept set at prior head 950da7d3b9 and at head differs in 0 cells; the synthetic union built from head's own EvaluatedExpressionInputSchema and evaluatedExpressionUnionRefusal with abort toggled differs in 0 cells and agrees with the real head in all 48. Structurally the same: a union accepts only an arm with zero issues, and abort only marks the failing arm aborted, so it can move which arm answers but never whether one accepts. Top-level issues moved in 14 cells, all at this one slot: the three blank-source spellings go from invalid_union + bare Invalid input to one custom issue at …condition.source carrying the published sentence; {dialect:'cel'}, the ast-only envelope, {dialect:'cel',source:5}, {dialect:'zzz',foo:1}, {dialect:undefined|null} and {dialect:'cel',source:null} go from custom at the slot to invalid_union at the slot with the same sentence; a wrong dialect beside a good string source (js, nope, CEL, and meta.rationale:5) goes from the misnaming source sentence to invalid_union + Invalid input. No other slot can move: TraceSamplingConfigSchema is mounted only by tracing.zod.ts itself (sampling: at line 755) and referenced from the dogfood ledger; the 108-cell population pin is green and the full spec suite passes except one script-tier file that fails identically at base for an environmental tsx path.
  2. Cross-slot consistency verified at head: {dialect:'js',source:'x'} answers invalid_union + Invalid input at the direct mount, at RecordAlertProps.visible, at successCriteria and at the tracing slot alike; the blank-source envelope answers one custom at source at all four; {dialect:'cel'} answers the published sentence at all four. The tracing slot now says what its 35 siblings say. Observation, not a defect: because the structured-filter arm now aborts on every failure, STRUCTURED_FILTER_DIALECT_REFUSED never surfaces at top level; it is present only in the nested invalid_union.errors[0], and the top-level answer for a wrong dialect with a good source is zod's bare Invalid input, which is exactly the sibling behaviour and what the PR body's table states.
  3. REWORK item 1 verified against the tree: the refine is present at head (.refine((value) => !('dialect' in value), { message: STRUCTURED_FILTER_DIALECT_REFUSED, abort: true })); the six named shapes are accepted at base 00115a8442 and refused at head; base ExpressionInputSchema refuses all six, so the record arm alone admitted them; the control {}, {service:'api'}, {attributes:{'http.route':'/v1/orders'}} is accepted at base, prior head and head. RecordAlertProps.visible: true/false accepted at all three commits, no prior-to-head difference; ServiceLevelIndicator.successCriteria: the structured object, one with percentile, and one carrying a stray dialect key are accepted at all three commits, no prior-to-head difference. The changeset, the migration entry's surface and acceptanceCriteria, and the generated registry.ts (check:migration-registry green) now carry the table, the FROM → TO and the wider sweep; the new text is true. The wider set of base-to-head refusals in my corpus ({dialect:undefined}, {dialect:null}, {dialect:'cel',source:null}, {dialect:'CEL',source:'x'}, whitespace-only strings) is covered by the declared rule "any object carrying a dialect key" plus the ruled two spellings, and every one of them was already refused at prior head, so none is delta. An object with only an inherited dialect is accepted at head because the refine runs on the record's parsed output; JSON cannot express it, and it is not a delta change.
  4. REWORK item 2 verified: z.toJSONSchema is byte-identical for the plain, refined and aborting-refined record under all three io modes ({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}), and the whole TraceSamplingConfigSchema projection is identical prior vs head once descriptions are stripped, so regeneration alone could not have moved the type cell; the fix at .describe() is in the tree and check:docs reports content/docs/references/** up to date, with tracing.mdx carrying the dialect rule and no internal issue id. packages/spec/json-schema/ is gitignored build output, so the wider-than-zod published schema is a build-artifact finding, not a tracked-file one.
  5. PUBLIC SURFACE: the delta adds zero export lines (control: the full PR diff adds 10, including printCelAst and evaluatedExpressionUnionRefusal); STRUCTURED_FILTER_DIALECT_REFUSED has exactly two hits in the repo, both in tracing.zod.ts, none in either barrel (control: EVALUATED_EXPRESSION_SOURCE_REQUIRED hits the root barrel, api-surface/shared.json and export-origins/shared.json). Blob ids by git rev-parse rev:path are cf260910f1… and 0429ff67a6… at head, prior head, 00115a8442, 62d830e54e and origin/main 30be2ac0bb; git hash-object --stdin of the head blobs reproduces both; the two directories show 0 changed files against base and against origin/main (control: content/docs/references shows 23). After a full spec build, build-api-surface.ts --check reports the surface unchanged.
  6. Internal issue ids: the .describe() and the regenerated page no longer carry (#15811); check:doc-authoring is green at head (15537 customer-facing strings, the hoisted const and the describe included in its recognised sinks) and at base. #15811 still appears in the migration entry's description and rationale strings (entry lines 10, 40, 59; registry 7829, 7859, 7878); those pre-date the patch round, are not recognised sinks of the gate, and do not project into docs/protocol-upgrade-guide.md (0 hits; the step-18 entry is not projected while the protocol is 17.x). Observation only, not a delta defect.
  7. Relied on from record 5715224308 without re-deriving: the strict-subtype measurement over 28 inputs and the falsity of the two tells (item 1 there), the 36-position census and the dogfood ratchet reading (item 6), item 2's docblock-only diff to shared/expression.zod.ts, the printCelAst round-trip and dark controls, the four-position analysis of permissive sibling arms (item 4; I re-measured two of them directly and they agree), and the cel-record-alert ledger reading (item 8). None of the 7 delta files touches those surfaces, and the merge is provably automatic.

② Semver level

The delta does not change the answer. It adds no export, and its only accept-set effect at any position is nil (0 differences prior head to head over 48 values as mounted); what it changes is refusal messages at one slot and the text that declares a narrowing already present at the prior head. minor plus the BREAKING banner plus the ADR-0087 disposition stands, and both guards pass on this head against the PR merge-base (check-changeset-no-major exit 0; check-adr-0087-registration exit 0 with the one declared-breaking changeset registered). @objectstack/formula: minor is untouched by the delta.

③ Boundary flags

  • out_of_scope_findings 1 (published JSON Schema wider than zod wherever a .refine() carries the rule): measured true by the byte-identical projection; the artifact is gitignored build output and the gap is general to every refinement, so filing rather than repairing is correct; carried as [finding] the published JSON Schema is WIDER than the zod schema it is generated from wherever a .refine() carries the rule — an author validating against packages/spec/json-schema/** gets a green for metadata the runtime refuses #18670 per the seat, which I did not open.
  • out_of_scope_findings 2 ({dialect:'zzz',foo:1} answered with the source sentence): measured true at head; the sentence is literally true for an object with no string source, and it is the pre-existing behaviour of evaluatedExpressionInputRefusal at all 36 positions, not a tracing cell. Noted-not-filed is acceptable as message quality only.
  • out_of_scope_findings 3 (over-long comment line in ui/action.zod.ts): cosmetic, not a contract matter; lint not re-run here.
  • out_of_scope_findings 4 (the missing review record): resolved, the record was posted late as 5715224308 with the seat's own explanation; the round's NOT MEASURED handling was correct.
  • Gate accounting: 110 derived families reproduced from dispatch-gates --commands on the head tree; the six exit-3 PREREQUISITE gates reproduce with their documented text; check:skill-examples exits 1 with "packages/spec/dist holds no .d.ts declarations — the package is not built", the same class; check:react-declaration-parity --strict with MANIFEST prints "111 spec-only divergences, 1 blocks missing from the registry" at head and at base 00115a8442, exit 1 both, so pre-existing and unmoved; check:cross-package-test-inputs is now exit 0 at head (the origin/main merge brought its fix), consistent with eight rather than nine non-zero exits. No red in disguise.
  • check:generated: 14 of 15 green without a dist and check:api-surface refusing as a prerequisite; after a build, all 15 up to date, exit 0, tree unchanged. The claim holds.
  • --pair 18638: exit 4 with C5 only, the same two tells at ui/action.zod.ts:833 (T2) and ui/component.zod.ts:1595 (T1); C1 cleared (needs:contract-review is on the PR). The delta alone raises no tell (exit 0, 4 files judged, 5 NOT MEASURED as changeset, page and tests). The PR still declares Clause-②: no, which is right; the matcher repair remains [finding] check-widening-tells.mjs reports T1 and T2 on lines added only because a zod options object was appended to an existing union — the clause-② enqueue gate refuses a diff that adds no key and no arm #18640's and this PR must not flip.
  • The major to minor deviation and the package-internal helper: settled by the prior record and undisturbed by the delta.
  • Escalation: none required.

Implemented-by: claude/issue-15811-evaluated-slot-narrowing
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⛔ 落地阻断 —— 契约复核 PASS,但本 PR 在 CI 上真红 16 条,且红是本 PR 自己造成的

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-17T14:0xZ。本条是席位自己的读数,⛔ 不是上一条复核记录(5715671761)的一部分,也⛔ 不推翻它。

两件事互不矛盾,先说清楚

契约复核的 PASS 成立且在其职权内:它测的是契约语义(接受集、semver 档、公开面),它在自己的隔离检出里只构建了 packages/spec,报告也如实写明了这一点。它没有、也不负责读 CI。

入队资格是本席的职权:「入队资格 = 每个 check 为 success 或预期 skip,⛔ 不是 required 子集」。本席读了,不合格。

实测(head 62902436d9,按该 sha 直接查 check-runs,⛔ 不绑事件带的 SHA)

42 条 check:success 21 · skipped 4 · failure 17。

逐条与 origin/main(30be2ac0bb)同名 check 对照:

结论 条数
在 main 上也红 1 —— TypeScript Type Check
在 main 上是 success,只在本 PR 红 16

⭐ 仪器是亮的:对照表两个分支都取到了值 —— TypeScript Type Check 命中「main 上也红」,证明该分支可达;⛔ 不是一个永远只会答「你的锅」的坏仪器。

在 main 上绿、只在本 PR 红的 16 条:
Build Core · Test Core + 6 个分片 · Dogfood Regression Gate + 3 个分片 · Dogfood Verify CLI · Type Check · workspace · Type Check · consumer gates · Type Check · debt ledger

根因 —— 一条,不是十六条

十六条全部塌在同一个包的 DTS 构建上(Failed: @objectstack/platform-objects#build),错误逐字:

src/pages/sys-user.page.ts(88,9): error TS2322: Type '{ dialect: "cel" | "cron" | "template";
  source?: string | undefined; ast?: unknown; meta?: {...} | undefined; }'
  is not assignable to type 'string | { dialect: ...; source: string; ... } | undefined'.
    Types of property 'source' are incompatible.
      Type 'string | undefined' is not assignable to type 'string'.

⇒ 这正是本 PR 的收窄本身:EvaluatedExpression* 要求 source: string,而 packages/platform-objects/src/pages/sys-user.page.ts:88 仍然递进一个 source 可选的信封。一个第一方消费者没有跟着改。

@objectstack/platform-objects#build 一挂,Build Core、三条 Type Check、六个 Test Core 分片、四条 Dogfood 全部连带塌掉。修好这一处,十六条应当一起回绿 —— 这是预测,⛔ 不是读数,由补丁轮实测。

这恰恰是收窄该有的样子,⛔ 不是复核失职

一次契约收窄本来就该把不合规的调用点照出来。复核测的是「收窄是否正确且已声明」——它是;CI 测的是「谁在用旧形状」——sys-user.page.ts:88 在用。两个读数都对,合起来才是完整判断。本 PR 声明了破坏性变更却没有修自己仓内的消费者。

处置


Generated by Claude Code

…aluated-slot-narrowing

Conflict resolved by hand in packages/spec/src/system/metrics.zod.ts: both
intents stack — main's new DurationSeconds import plus this branch's swap of
ExpressionInputSchema for EvaluatedExpressionInputSchema and the union refusal
helper. ExpressionInputSchema has no remaining use in the merged file.

The two both-sides-edited os-regen artifacts take main's side in this commit;
the regeneration follows as its own commit.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
Discharges the os-regen deferral the merge commit recorded. Both pages carry
main's incoming content plus this branch's narrowing: the evaluated-slot
envelope now prints `source: string` instead of `source?: string`, and
metrics.mdx keeps main's `window.durationSeconds` rename.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Clause-② 复核记录 —— 合并增量复核,PASS

项 值
判定 PASS
落地 head 34a63b9d583c881d4c45d7187206417b0c82c17b
档位 claude-fable-5-1 —— 复核轮 JSONL 里逐条 harness 打戳的 message.model 计数 98 / 98 在档,0 条脱档。⛔ 不是派发时的 model 参数,那不是证据
范围 合并增量:上一次 PASS 的 head 70407326463d 与本 head 之间,8 个 blob 变了、51 个逐字节相同。那 51 个不重审

为什么范围是 8 而不是整条二点差分

70407326463d..34a63b9d583c 的朴素两点差分会把 main 自那以后落地的一切都卷进来 —— 那些已经审过也已经落地了。分支自己贡献的 59 条路径里,逐 blob 比对得到 51 同 / 8 异,8 条即全部复核对象,每一条都能归因到某次合并或重生成:regen B 改 4(恰为 round B 的 routed both-sides)、regen A 改 3、两次合并各自解决 1 条未路由路径。

五问读数(每问都带已点亮的控制项)

  1. 四条 routed 产物 + root.txt 两侧是否都活着 —— main 侧新增行在 FINAL 缺失 0/0/0/0/0,分支侧新增行缺失 0/0/0/0/0;删除向同样全 0。⭐ 控制项在每一片的两侧都点亮(main 侧 21/21/4/14/1,分支侧 11/9/1/9/2)。更强的一条:-U0 层面 (MAIN→FINAL) 与分支自己的 delta 逐字节相同,(OLDBR→FINAL) 与 main 的 delta 逐字节相同 —— 即每个终态文件 = main + 恰好分支那一处 = 分支 + 恰好 main 那一处,连顺序都对。三方之外的「交互行」:0。
  2. registry.ts —— main 加 1 条、分支加 1 条,两侧都没删 id;merged-vs-main 恰为分支那 121 行,merged-vs-branch 恰为 main 那 74 行;行数 17142 → 17337 严格可加;两个 entry 文件都在。⭐ 生成器闸门 --self-test --check exit 0(226 semantic),并配了一个会红的反控:把 main 的 registry.ts 连同两个 entry 文件一起摆进去 → exit 1 stale。
  3. dropped-refinements.baseline.json —— 确认是 MIXED(文件自述「Hand-edited on purpose and with no gen: script」)。合并 1 里 main 删了 data/SSLConfig、分支加了两条,合并结果同时保住了 main 的删除与分支的两条新增;终态 202 键 = main 的 200 + 分支的 2,main 有而终态没有的键:无。计数器由 regen 从生成器自己打印的那行重新推导,⛔ 非手抄:553 refinement site(s) across 202 published schema(s) 与 367/3 逐字吻合。
  4. component.zod.ts —— .refine( 在 base / 分支 / main / merged 四处皆为 0,superRefine 四处皆为 1,.strict() 四处皆为 4。⇒ ⛔ 没有 .refine() 被删,这个计数在两个父提交和合并结果上都是不变量。两侧 hunk 不重叠(1591–1604 vs 1817–1860),行数 3750 → 3799 可加。
  5. 产物能否从合并树复现 —— 已测,是(⛔ 未按 NOT MEASURED 放过)。gen:schema 写模式 0 漂移;build-docs.ts --check exit 0「224 generated files in sync」;build-api-surface-declarations.ts --check exit 0「17 entry points, 5345 declarations」。⭐ 每一条都配了会红的反控:把旧 blob 分别塞进 root / data / automation / ui 四片逐片试,四次各自 exit 1 且各自点名自己那一片 —— ⛔ 不是一片探针冒充四片控制。

复核方自报并当场改正的一处仪器错误

首轮「51 条同 blob」扫描曾误标 5 条为 MAIN CHANGED:git rev-parse rev:path 对不存在的路径会回显参数本身,于是缺失被读成了一个 sha。加 --verify -q 重跑后,这 5 条确认为分支新增文件,main 改动数 = 0。⇒ 记录在案,因为这是个会静默说谎的读法。

与本 PR 正文的关系

正文〈Base catch-up〉小节里的数字与本复核独立测得的一致(17142+121+74=17337、3750+4+45=3799、计数器 202/553/367/3)。⚠️ 正文中两处早于本 head 的读数已在本轮显式钉住(闸门 110/104/6/0 钉到 e892c86e271;all 15 artifacts 标为被 #18971 取代,现注册数 16,并保留原句逐字不改写)。

⛔ 复核方未推送、未改 PR、未动工作树。


Generated by Claude Code

@os-litant
os-litant added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit ce57857 Sep 18, 2026
46 checks passed
@os-litant
os-litant deleted the claude/issue-15811-evaluated-slot-narrowing branch September 18, 2026 16:01
os-elon-musk pushed a commit that referenced this pull request Sep 19, 2026
…/main

Discharges the os-regen deferral from the prior merge commit. root.txt and
shared.txt now reflect main's #18638 evaluated-expression-slot narrowing
(source becomes required across the composing slots) plus the drift picked
up while catching this branch up to main's current tip. No hand edits.
os-elon-musk pushed a commit that referenced this pull request Sep 19, 2026
Main shipped the identical /`expression` return-type narrowing to
EvaluatedExpression first, under #18638 (card #15811) -- confirmed by the
merge: both sides made the same change independently, and ADR-0137's own
status line says its PR carries no schema change. Re-announcing that
narrowing here would duplicate #18638's own changeset entry in the same
release. Drop the redundant paragraphs and keep only what #17778 alone
ships: the ADR-0137 predicate fault-semantics contract and its ADR-0089
addendum.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… set that gained a value (objectstack-ai#18724)

Part of objectstack-ai#18640

Clause-②: no

`scripts/pm/check-widening-tells.mjs` raised **T2** — "a new member of a
closed set" — on a line
that appends a zod options object to a union whose member list is
unchanged. The card measured it
at `packages/spec/src/ui/action.zod.ts:833`, where
`const ActionConditionInputSchema = z.union([z.boolean(),
ExpressionInputSchema]);` grows a
`, { error: … }` argument. The set carries the same arms before and
after.

This PR repairs that row and pins the shape. It deliberately does
**not** repair the card's second
row; see "The half that stays open" below.

## The control set IS the finding

Four probes, one synthetic file on the judged surface, run as
`node scripts/pm/check-widening-tells.mjs --declaration no --diff …`.
Exit codes read from `$?`
after a redirect, never through a pipe.

| the edit | spelling | BEFORE (`a7e9a6600b`) | AFTER (this branch) |
|---|---|---|---|
| options object appended to an existing union | `const` binding,
members INLINE | **exit 4, one T2** | exit 0 |
| the identical edit | `const` binding, one member per line | exit 0 |
exit 0 |
| the identical edit | a KEYED property, members inline | exit 0 | exit
0 |
| the same append **plus a third arm** | `const` binding, members inline
| exit 4, one T2 | **exit 4, one T2** |

Row 1 against rows 2 and 3 is the whole finding: one semantic change,
three spellings, two opposite
verdicts, decided by nothing but where the author put the newlines and
whether the set is bound to a
name or to a key. Row 4 is the counterfactual — a real arm added beside
the options object still
reports, with its own file:line.

## Why no reading in the file could already see that line

- **objectstack-ai#16822's `rewritesExistingOpener`** reads an opener-ONLY line by
construction. Its pattern
refuses a line that already carries members, on purpose, so an inline
opener was never in its
  population.
- **objectstack-ai#16943's replacement budget** cannot reach the line either:
`memberTellKind` answers `null` for
`const C = z.union([…])`, which names no key and is no bare element, so
the row neither earns on
  the removed side nor spends on the added one.

The line therefore falls through every reading in the file to the raw
`CLOSED_SET_OPENER` test,
which is the whole of the tell for it.

## What was added

`closedSetBindingMembers` (the binding a closed-set declaration names,
plus the members the same
line carries) and `respellsExistingClosedSetBinding`, consulted in the
opener branch. The evidence
is positive, block-local and absent by default, like every decline in
this file: the block removed a
line declaring the SAME binding, both member lists are readable inline,
and the added list adds no
NET member.

The arithmetic is objectstack-ai#16943's own — "the ruling this implements is
replacement-vs-net-addition, not
spelling" — applied to the members the inline line carries, so this
reading is **bounded exactly by
what the multi-line spelling of the same block already does**, and by
nothing wider. Three guards
keep it that narrow: a KEYED line is refused outright, an ANONYMOUS set
(no binding) is refused, and
a list that does not close on the line is unreadable and keeps the tell.

`keyedClosedSetMembers` is refactored onto a shared
`inlineClosedSetMembers` reader so the keyed
spend and the binding spend can never disagree about which members a
line carries. That half is
behaviour-preserving: the 381 pre-existing self-test cases pass
unchanged.

## The half that stays open — and why this PR does not buy it

The card's second row, `packages/spec/src/ui/component.zod.ts:1595`, is
a **keyed** line whose union
member was **renamed** (`ExpressionInputSchema` to
`EvaluatedExpressionInputSchema`). It fails
objectstack-ai#17618's fact 3 — "the added list a SUBSET of the removed one" — and it
still reports. Measured:
`--declaration no` over PR objectstack-ai#18638's own pushed bytes goes from two tells
(exit 4) to one tell
(exit 4).

That row is **not** an accidental-spelling variable. The keyed
population has no control bounding a
relaxation: a keyed value whose list opens on a later line is unreadable
and reports too, measured
on the same harness. Whether a renamed member should defeat a subset
test is a ruling about fact 3
— a ruling that would necessarily also silence a member SWAPPED for a
wider schema, because no
line-shaped matcher can tell a subtype from a supertype. The card's own
direction rule is quoted
verbatim rather than paraphrased:

> ⚠️ 注意方向:**拒绝过多的门禁是吵但安全的;拒绝过少的那个才危险。** ⛔ 任何修复都不得靠**普遍放松**矫正器来买安静。

So it is left to a decision, not taken here. PR objectstack-ai#18638 stays where it
is; that outcome is acceptable
and a clause-② gate that under-reports is not.

## Evidence

**Self-test** — `node scripts/pm/check-widening-tells.mjs --self-test`:
exit 0, **403 cases pass**
(381 before this branch). The new battery is `objectstack-ai#18640 — an inline closed
set RE-SPELLED at the same
binding is not a set that gained a value`, floor 20, registering 22: the
control set, the specimen,
the counterfactual (an arm added beside the options object), and the
dark, different-binding,
brand-new, widened-enum and new-key controls that must all still report.

**Ablations** — each leg mutated the file on disk, proved the mutation
landed by an anchor
occurrence count plus a `git hash-object` comparison against the HEAD
blob, ran the self-test, then
restored with `git checkout HEAD -- …` and proved the restore by an
empty `git diff HEAD` and a blob
hash equal to HEAD's:

| leg | self-test | cases that turned red |
|---|---|---|
| the opener-branch wiring removed | exit 1 | 4, headed by the specimen
and the clean-pair case |
| the net-delta arithmetic replaced by an unconditional accept | exit 1
| 5, headed by **the counterfactual** and the widened-enum control |
| the keyed-line guard dropped | exit 1 | 1 — the case pinning that the
two populations cannot merge |

Leg 2 is the one that matters for direction: if a later author loosens
the arithmetic, the
counterfactual turns red.

**Price** — measured at `222ef3f1ee` (this branch's head) over the 749
commits touching the judged surfaces in the history provably
present in this tree (`node scripts/pm/git-history.mjs ensure
--days=30`: floor 2026-08-11, tip
2026-09-17; the clone is shallow and the window is stated because a
partial is not a zero). Of the
20,452 tell rows the previous reading raises, **20,452 stand and 0
move**. 55 of the 3,902 T2 rows
in that window sit on a line this reading can READ, and it declined none
of them — none had a
same-binding removal in its own change block. **No row anywhere in that
window begins reporting.**
Positive control on the harness itself: the same two modules over PR
objectstack-ai#18638's diff read 2 rows
before and 1 after.

**Lint** — a DECLARED narrowing, not a whole-repo run. `pnpm exec eslint
--no-inline-config
--format json scripts/pm/check-widening-tells.mjs`: exit 0, **1 file, 0
errors, 0 warnings** (file
count read from the JSON). The population `eslint .` would check, read
from eslint's own config
rather than guessed — `isPathIgnored` over every tracked
lintable-extension file — is **6,818 files,
0 of them ignored**. The narrowing is sound because this repo's single
`eslint.config.mjs` enables
no type-aware linting for ANY file (its own comment: "no
`parserOptions.project`, no typed
`@typescript-eslint` rules"), so a one-file diff cannot move the verdict
on any file it did not
touch. The whole-repo run belongs to CI.

**Population** — on the tree at `a7e9a6600b` plus this branch: 50 lines
in 31 files of the 998
judged source files are non-keyed inline closed-set declarations with a
named binding, against 666
keyed inline sets (objectstack-ai#17618's population, untouched) and 311 opener-only
lines (objectstack-ai#16822's population,
untouched).

## The quiet direction this buys

A one-for-one member SWAP at an existing binding — `z.union([A, B])` to
`z.union([A, C])` — now
declines, and `C` may accept more than `B` did. That is not a new class:
objectstack-ai#16943 bought exactly that
silence for every set spelled one member per line and measured it over
82 commits (34 declines, not
one of them a member rename). This removes the accidental exception, not
the rule. What still
catches a swap that slips past is what caught it for the spelled-out
form: `check:api-surface` on
any exported name it moves, `check:authorable-surface` on any authorable
key it changes, and the
ADR-0087 registries. The OVERTURN CONDITION is written into the
docblock: the first LANDED widening
carried by a same-binding inline member swap closes it by reading the
members' own declarations.

## Not a changeset

`scripts/pm/**` ships in no package's `files[]`, so this publishes
nothing — `skip-changeset`.

## Acceptance notes

- **To file (3 classes).** `respellsExistingClosedSetKey`'s fact 3
reports on a KEYED closed-set
value whose member was RENAMED — reproducible at
`packages/spec/src/ui/component.zod.ts:1595` on
PR objectstack-ai#18638 and on a synthetic probe. Class (a), and it needs the fact-3
ruling above before a
repair can be written. Dedupe words: `widening-tells keyed subset
rename`, `objectstack-ai#17618 fact 3 member
rename`, `respellsExistingClosedSetKey subtype`, `clause-2 keyed union
arm renamed`,
  `component.zod visible union rename`.
- **Noted, not filed.** The self-test verdict sentence does not
enumerate every declared battery
(objectstack-ai#18560's is absent from it). Cosmetic prose drift with no reader; a
clause for this round was
added while the sentence was open. Successor: the next round that edits
the sentence.
- **Noted, not filed.** `closedSetOpenerBinding` accepts an EMPTY
binding while
`closedSetBindingMembers` refuses one. The asymmetry is deliberate here
(an anonymous inline set
has no declaration identity) and objectstack-ai#16822's half was left untouched.
Successor: none.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…asure — narrow `DashboardWidgetSchema.values` for the metric/kpi/gauge/solid-gauge/bullet family (objectui#8894 ruling D) (objectstack-ai#18720)

Fixes objectstack-ai#17779
Clause-②: yes (narrowing)

Executes maintainer ruling **D** on objectui#8894 (decision batch objectstack-ai#119
item 4, 2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 —
judge the protocol wrong: a metric-family widget takes exactly one
measure. The direction was not re-opened here.

## What changed

`DashboardWidgetSchema.values` was `z.array(z.string()).min(1)` with
**no upper bound on any widget type**, so a `metric` tile could declare
three measures; the dataset query selected and computed all three and
the tile rendered `values[0]`. The other two were queried and dropped on
the floor (objectui#7293 defect 1). objectui#8887's sub-caption made the
tile honest about dropping them; it did not make the document legal.

- `checkDashboardWidgetMetricMeasureArity` — a new exported object-level
check, chained onto the same door by identifier, refusing more than one
measure on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` and on
a widget that declares no `type` (it defaults to `metric`, and the
message says so rather than claiming the author wrote it). One `custom`
issue at `values`, naming the widget's `id`, the count, and the authored
`type`, and prescribing one measure per tile — "make N tiles for N
measures" — plus the visuals that DO render several numbers.
- **Exactly one is a conjunction**: the field's own `.min(1)` still owns
the empty array (`too_small`, unchanged, and the new check deliberately
adds no second issue there); the new check owns the upper bound.
- `.changeset/17779-...` — `minor`, **BREAKING** banner, ADR-0087
disposition `registered
dashboard-widget-metric-family-multi-measure-refused`.
-
`packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts`
— one new entry file, plus the `gen:migration-registry` lap. No other
file in that directory was touched and nothing was hand-edited inside
the generated regions of `registry.ts`.
- The `values` doc string now states the arity rule it enforces, so the
generated reference page stops saying only "at least one".

## The three questions the dispatch asked, answered by measurement

### 1. `superRefine`, not a per-type union arm — because a union
destroys every other diagnostic on this door

Eight widget bodies through `z.union([metricArm, otherArm])` versus one
more `.superRefine` on the strict object, measured on this tree:

| body | union arms | the spelling shipped |
|---|---|---|
| `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the
strict-object refusal, naming the key + the history sentence |
| `categoryField` / `valueField` | `(root) invalid_union: Invalid input`
| the `WIDGET_GUIDANCE_SETS` ADR-0021 prescription |
| `titel` | `(root) invalid_union: Invalid input` | "Did you mean
`titel` → `title`?" |
| `type: 'ziggurat'` | `(root) invalid_union: Invalid input` |
`invalid_value` at `type`, listing all twenty |
| `metric` + 3 measures | `too_big` at `values` | the curated `custom`
refusal at `values` |

Four of eight bodies lose their whole diagnostic to one bare `Invalid
input`. That is not a new observation on this file: the `compareTo`
docblock already records it for the same reason (objectstack-ai#5014 — "a union
collapses into one bare `Invalid input` on the wire … A plain strict
object's errors reach the author"), and `view-union-diagnostics.test.ts`
is the entire apparatus objectui needed **because** `ViewMetadataSchema`
is a union. A second union here would commission that apparatus again to
buy a refusal the object-level form gives for free. Second datum,
measured: zod 4.4.3 throws `Cannot overwrite keys on object schemas
containing refinements. Use .safeExtend() instead` on a plain
`.extend()` that redeclares a key, so the arms cannot even be built from
the existing door without `.safeExtend()` or a duplicated declaration.

### 2. `major` does collide with `check-changeset-no-major` — so the
changeset is `minor`

The guard is **armed**: there is no `.changeset/pre.json`, so the RC
exemption does not apply, and the only other route is the `allow-major`
PR label whose own error text says "a whole-stack major release is
genuinely intended" — false for this PR. Its header states the
convention: every publishable package is in the Changesets `fixed`
group, so one `major` promotes all ~70 packages; during the launch
window a breaking change ships `minor` and **breaking-ness is carried by
the BREAKING banner plus the ADR-0087 disposition, not by the bump
level**. `pr-automation.yml`'s "WHICH LEVEL" prose says the same in the
place the author reads it. So the card's "major changeset" is satisfied
as `minor` + `**BREAKING**` + `registered ...`, and
`check-adr-0087-registration --base origin/main` reads the changeset
back as `[BREAKING+bang+clause-②-narrowing] registered
dashboard-widget-metric-family-multi-measure-refused`.

### 3. The migration entry's acceptance criteria, re-derived from what
the code refuses

Not a restatement of the card. Two things the card's wording implies
that the machinery does **not** do, both measured and both written into
the entry:

- **The TODO cannot name your dropped measures.** `applyMetaMigrations`
maps `step.semantic` straight onto the result (`chain.ts`) with no
per-document interpolation and no filtering by whether the stack even
carries the shape, and `SemanticMigration` has only static string
fields. `os migrate meta` therefore prints the entry's prose, not a
list. The **refusal** is what names them, per widget, on the re-parse —
so the entry tells the author to drive the fix off `os build`, not off
the migrate output.
- **Splitting into N tiles is not attempted**, as the card says — and
the entry states why in the registry's own terms: N tiles need N ids and
N boxes on a 12-column grid, which is a layout fact about a dashboard
the registry has never seen.

The rest of `acceptanceCriteria` is the measured accept/refuse matrix:
which door refuses (publish, not objectui's `.shape`-mirror editor), the
empty-array carve-out, the aborting `invalid_value` on an unknown
`type`, the un-reachable "does this measure exist in the dataset", and
the fact that `.omit()` / `.pick()` / `.partial()` already threw before
this change.

## Controls

**LIT** — a legal single-measure metric tile parses **identically before
and after**, and the non-metric families are untouched. Sixteen bodies
through `DashboardWidgetSchema.safeParse`, before and after the change:

| body | before | after |
|---|---|---|
| `metric` + 1 measure | ACCEPT, `values: ["amount_sum"]` | ACCEPT,
`values: ["amount_sum"]` |
| `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` + 2–3 measures |
ACCEPT (all five) | REFUSE `values:custom` (all five) |
| no `type` + 3 measures | ACCEPT, `type: "metric"` | REFUSE
`values:custom` |
| `bar` / `line` / `table` / `pivot` / `funnel` + 3 measures | ACCEPT |
ACCEPT (unchanged) |
| `metric` + `values: []` | REFUSE `values:too_small` | REFUSE
`values:too_small` (one issue, not two) |
| `type: 'ziggurat'` + 3 measures | REFUSE `type:invalid_value` | REFUSE
`type:invalid_value` (alone) |
| `metric` + 3 measures + `bogusProp` | REFUSE `unrecognized_keys` |
REFUSE `unrecognized_keys` |

The whole taxonomy is covered by a pin that asserts the metric family
plus the fifteen others **is** `ChartTypeSchema.options`, so a new chart
type cannot land uncovered by either list.

**DARK** — things that must read **0**, with paths and counts:

- `.min(1)` **array** keys in `packages/spec/src/ui/dashboard.zod.ts`
other than `values`: **0**. The file has exactly two `.min(1)` code
sites at the branch point — `values` (line 706) and `dashboard.columns`
(line 1151, `z.number().int().min(1).max(24)`, a number bound, not an
array). The latter is byte-identical after the change; every other new
`.min(1)` occurrence in the file is inside a docblock.
- `ReportSchema.values` (`packages/spec/src/ui/report.zod.ts`, lines 237
and 314) is a separate declaration, `optional()`, with no `.min(1)` and
no arity check, and its `type` enum (`tabular` / `summary` / `matrix` /
`joined`) contains **0** metric-family members. Untouched, and not the
same defect.
- Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` /
`.md` / `.yaml` **at the branch point** `72dd95fa5a`: **187**
brace-local literals carrying a `values: [...]`, **39** of them on a
metric-family `type` (both lit controls), and **0** of those carrying
more than one measure. Nothing in the monorepo moves. On this branch the
same scan reads 205 / 49 / **7**, and all seven are the fixtures this PR
added.
- `check:authorable-surface` is green with no regeneration: **0**
authorable keys move. `check:api-surface` reports `0 breaking
(removed/narrowed), 1 added` — the new exported check.

## Verification

Red before green, with the mutation proved on disk and the restore
hash-verified:

```
HEAD blob     : 30c6d78
worktree blob : 30c6d78   (at HEAD before the mutation)
anchor occurrences BEFORE: 1   AFTER: 0   injected line: 1
mutated blob  : 90548649227c3971f16b7dc85b02e1bab8155f96   (differs -> the edit really landed)
RED   vitest exit=1   17 failed | 205 passed (222)
restored blob : 30c6d78   git diff HEAD on the path: empty
GREEN vitest exit=0   222 passed (222)
```

The mutation removed only the
`.superRefine(checkDashboardWidgetMetricMeasureArity)` attachment,
leaving the function declared — so the 17 reds are the door's behaviour,
not a compile failure. The script carried a `trap ... EXIT INT TERM`
restore against an absolute `git rev-parse --show-toplevel` path,
restored with `git checkout HEAD -- path` (never a bare `git checkout
--`), and proved the restore by blob hash **and** an empty `git diff
HEAD`.

- `pnpm --filter @objectstack/spec test` — **486 files / 13933 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec typecheck` — exit 0
(`check:scripts-typecheck` and `check:test-typecheck` included; the
test-layer ledger held at 54 files / 259 errors / 144 pinned signatures,
shrink-only).
- `pnpm --filter @objectstack/spec check:generated` — **all 15 generated
artifacts up to date**, exit 0, after regenerating exactly the three it
proved stale (`api-surface/`, `export-origins/`,
`content/docs/references/**`).
- Changeset gates: `check-adr-0087-registration --base origin/main` exit
0 (+ `--self-test`, 384 assertions), `check-changeset-no-major --base
origin/main` exit 0, `check-empty-changeset --base origin/main` exit 0.
- `pnpm check:nul-bytes` exit 0 (8812 text files, no raw control bytes),
plus `check:widget-option-census`, `check:liveness`,
`check:exported-any`, `check:dual-source-exports`,
`check:entry-nameability`, `check:empty-state`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage`, `check:merge-driver`,
`check:pm-widening-tells`, `check:spec-docblock-symbol-anchors`,
`check:dts-closure`, `check:published-files`, `check:spec-parsed-alias`,
`check:page-declaration-shape`, `check:corpus-claim-drift`,
`check:skill-examples`, `check:docs-transcript-drift`,
`check:doc-formula-expressions`, `check:variant-docs`, `check:llms-txt`,
`check:yaml-examples`, `check:objectui-pin-citations`, and the ten doc
gates the regenerated `.mdx` newly derives — every one exit 0.
- **Repo-wide lint, not a narrowing**: `node --stack-size=4000
node_modules/eslint/bin/eslint.js . --no-inline-config --format json` at
`ea17ab8491`, 81s — **6822 files linted, 0 errors, 0 warnings**, exit 0.

## Migration-entry adjacency — checked, not assumed

`packages/spec/src/migrations/entries/` is one file per entry and the
entries README records the measured objectstack-ai#8344 table: two in-flight
registrations merge clean **unless** their ids are adjacent in sort
order or both are the first entry of a new major. Enumerated the `18.*`
semantic directory and every open PR's file list on 2026-09-17:

- In-flight ADDED semantic registrations:
`ui-list-view-groupbyfield-padded-refused` (objectstack-ai#18695),
`structured-region-body-pause-and-end-refused` (objectstack-ai#18688),
`evaluated-expression-slots-source-required` (objectstack-ai#18638),
`manifest-id-reverse-domain-required` (objectstack-ai#18319). (objectstack-ai#18420 modifies an
existing entry, which is not an insertion.)
- This entry's immediate neighbours in the sorted set are
`dashboard-header-modal-target-page-only` and
`dashboard-widget-stage-order-non-funnel-refused` — **both already
landed on `main`**, neither in flight — and it is not the first entry of
major 18. Neither ejection row applies. The seat's expectation about
objectstack-ai#18695 held, and was verified rather than assumed.

The two projections the README names came back **byte-identical, and
that is correct rather than a skipped step**: `build-spec-changes.ts`
and `build-upgrade-guide.ts` both loop `for (major =
MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++)`, and
`PROTOCOL_MAJOR` is **17** while this entry registers under **18**. Both
were regenerated anyway and `check:spec-changes` / `check:upgrade-guide`
are green.

## Acceptance notes

Noted, not filed — neither is a reproducible defect, a contract
violation, or a metadata-authoring trap:

- **zod 4.4.3 refuses `.extend()` that overwrites a key on a refined
object** ("Use `.safeExtend()` instead"), measured here while probing
the union spelling. It is a trap for the next author who mirrors or
re-arms this door — recorded in the new check's docblock and in the
migration entry, which is where that author looks. Successor: whoever
lands objectui#8894's half, which must re-attach this export onto a
`.shape` mirror.
- **An ADR-0087 semantic entry cannot name per-document values.**
`applyMetaMigrations` emits `step.semantic` unconditionally and
`SemanticMigration` carries only static strings, so a card instruction
of the form "emit a structured TODO naming X" is unsatisfiable as
literally written — the refusal message is the only per-document
channel. Recorded in this entry's `acceptanceCriteria`. Successor: the
next card that writes that instruction.

## Downstream, not in this PR

Card item 3 (objectui's contract twins gain the refusal pin; the runtime
warning becomes the door refusal) is the objectui half and objectui#8894
is `pm:blocked` on this card. Nothing in `../objectui` was touched.
Until that package imports and chains
`checkDashboardWidgetMetricMeasureArity`, its `.shape`-mirror editor
keeps accepting three measures on a `metric` and the author meets this
refusal at publish — stated in the check's docblock and in the migration
entry rather than left implied.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

---

> ⏱️ **席位代改正文(dev 只写一次,⛔ 不 PATCH 正文;事后要改的由本席代写)。** 两处:
>
> - **`applyMigrationChain` → `applyMetaMigrations`(2 处)** ——
前者在树上**不存在**;
> 真函数是 `packages/spec/src/migrations/chain.ts:68`,CLI 调它,根 api-surface
导出它。
>   同一处错名也写进了 ADR-0087 语义条目、并经 `gen:migration-registry` 复制进
>   `registry.ts:6765` —— 那段文本会被 `os migrate meta` 在协议 18 打印出来,
>   所以读者照着 grep 会一无所获。已随 `3b15ca1254` 修正(条目 + 重生成,⛔ 未手改 registry.ts)。
>   ⭐ 这一条由**达档隔离契约复核**判出(记录见下方 PASS/FAIL 评论),⛔ 不是本席自己看出来的。
> - **`Clause-②: no (narrowing)` → `yes (widening)`** —— 该行**只定路由**,⛔
非终审:
>   章程原文「只定是否必过席内契约复核的保守方向」,机械地板「新导出符号…恒 `yes`」。
>   本 diff 在 `api-surface/ui.json` 上**净增一个导出符号**
> (`checkDashboardWidgetMetricMeasureArity`,+1 / 移除 0,本席对着 merge-base
`72dd95fa5a` 实测),
>   ⇒ 地板落在 `yes`。认领侧早已是 `yes (widening)`,正文与 changeset 两个载体**落后于它**;
>   changeset 已随 `881db1280d` 对齐,并在行内写明两条轴(接受集**收窄**、公开面**扩大**),
>   免得 CHANGELOG 读成「本改动放宽了行为」。
>
> ⚠️ **破坏性未受影响**:`check-adr-0087-registration` 仍读作 breaking,
> 经 `**BREAKING**` 横幅与摘要里的 `!`;它失去的 `clause-②-narrowing` 信号从来不是唯一载体
> (实测 `[BREAKING+bang]`,exit 0)。
>
> ⏱️ **再正一次(席位):`yes (widening)` → `yes (narrowing)`。** 上一版本席以为「收窄行为 +
扩大公开面」在这套两态词表里没有正确拼法,于是取了 `widening`
并写了一段话解释「它不是那个意思」。**那个前提是错的**:`readClause2Line` 认 `yes
(narrowing)`,而`check-adr-0087-registration` 的自测逐字命名了这个形状 ——「the
`narrowing` arm beside a `yes` value — a diff that **widens AND
narrows**」。⇒ 值仍是 `yes`(机械地板:新导出符号),但**臂**改回
`narrowing`,`clause-②-narrowing` 信号随之回到 ADR-0087 门禁(实测
`[BREAKING+bang+clause-②-narrowing]`,exit 0)。⭐ 这一条由第二次达档复核在 ③
里作为**边界旗标**提出,⛔ 不是 FAIL;本席自己验过词表才动手。⚠️ 顺带一提 `no (widening)` 读作
**malformed** —— 臂不是自由的:`no` 只配 `narrowing`,`yes` 两者皆可。

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ast 100 is UNJUDGED rather than a truncated claim pool (objectstack-ai#18799)

Fixes objectstack-ai#18683

Clause-②: no

## The defect

`scripts/pm/check-clause2-carriers.mjs` read a card's comment thread
with ONE request — `/issues/{n}/comments?per_page=100`, no `page=`
ladder, no short-read check — while the two sibling list reads in the
same file paged to a declared cap and answered `null` (UNJUDGED, never
clean) when they hit it. One file, two OPPOSITE defaults on "I did not
read everything", and the fail-OPEN one was the read that arbitrates
OWNERSHIP: the governing-claim pool, its membership, and the `Clause-②`
declaration read out of it all come from those rows. A thread past 100
comments handed the pool its first page and nothing said the tail had
been dropped, so a claim written past row 100 was not superseded — it
was never a candidate — and a superseded carrier governed in its place.

## The before-reading, on a 101-row fixture

Driven end to end through the real CLI against a stubbed board
(`--pair`, no network), on `main` `d9ba33df4c` (script blob
`d753e2a8cf06d8f72c436e0a1917b2fecb8be813`). Two fixtures, both 101
rows, both differing from a complete thread only past the page boundary.

| fixture | BEFORE (`main`) | AFTER (this PR) |
|---|---|---|
| 100 claim-free rows, the 101st the only `Claim:` | `card-comments: 100
row(s)` · `claim.selected: none — no comment on this thread carries a
line beginning \`Claim:\`` · **exit 4, row C2 `absent`** |
`card-comments: 101 row(s)` · the 101st claim is the pool ·
`claim.clause2-line: DECLARED \`no\`` · **exit 0** |
| row 1 an older `Claim:` declaring `yes`, the 101st a newer one
declaring `no` | `claim.clause2-line: DECLARED \`yes\`` from the
SUPERSEDED carrier, which is not even listed as rejected · **exit 4, row
C3** | the newer claim governs, the older is listed REJECTED/SUPERSEDED
· `DECLARED \`no\`` · **exit 0** |

The second row is the fail-OPEN direction stated as a measurement: one
thread, two readings, and they disagree on the declaration itself.

## The ladder, and the cap

All three list reads now go through one `pagedListRead` helper — it
pages to a declared cap, stops on the FIRST short page (no wasted
request), and on the cap files the one shared `pageCapNote` sentence and
answers `null`. `readCarrierEvents` (`EVENT_PAGE_CAP` 10) and
`readPullFiles` (`FILE_PAGE_CAP` 3) keep their caps to the number; what
they gain is that the third read can no longer hold a different default.

`COMMENT_PAGE_CAP` is **10** pages = 1,000 comments. Sized on this
board, read 2026-09-17 off the open-issue list rows (550 rows listed,
cross-checked against `open_issues_count` = 550):

- longest open thread of any kind: seat post objectstack-ai#6015 at **895** comments —
nine pages;
- next four: objectstack-ai#12708 at 365, objectstack-ai#6023 at 241, objectstack-ai#6017 at 206, objectstack-ai#6024 at 187;
seat post objectstack-ai#7623 at 71;
- longest thread carrying a queue label: objectstack-ai#13799 at **117** (`pm:queue`,
p2, unassigned);
- longest card in the clause-② population (28 pairs the sweep derived
that day): objectstack-ai#17534 at **14**.

So ten pages clears the whole board today with a page to spare, and it
is the same ten `EVENT_PAGE_CAP` uses — a reader comparing two caps in
one file should have to remember one number.

## The input record

The diagnosis key stays `comments`, so every sentence already keyed to
it still finds its diagnosis. Two declared fields are added to
`INPUT_RECORD_PAIR_FIELDS`, one per thread this file reads:

```
pair.1.card-comments: 101 row(s)
pair.1.card-comment-pages: 2 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE
pair.1.pr-comment-pages: 1 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE
```

and, when the cap is what stopped the read:

```
pair.1.card-comment-pages: CAPPED — 10 of 10 page(s) of 100 comments each were requested and EVERY ONE came back full, so the tail is past the cap and the thread is UNREAD (UNJUDGED) — ⛔ never a truncated pool, ⛔ never a clean reading
```

A thread of exactly 100 rows and a thread whose tail was dropped are the
same `100 row(s)` in every other line the block prints; they differ
here, because the complete one stopped on a short page and the truncated
one did not stop at all. The request ledger PR objectstack-ai#18681 added shows the
same ladder from the other side — request objectstack-ai#3 is now
`…/comments?per_page=100&page=1` and objectstack-ai#4 is `&page=2`.

## The pins

A new `--self-test` battery, `objectstack-ai#18683: the card-comment read pages to a
cap — past 100 is UNJUDGED, ⛔ never a truncated pool`, 27 cases,
declared in `SELF_TEST_BATTERIES` with the roster floor raised 29 → 30.
It drives the ladder with an offline page server that reproduces
GitHub's own semantics and counts the requests; ⛔ no network. What it
holds: the 101st claim ENTERS the pool and GOVERNS, and its line is what
the limb reads; the same thread cut at 100 reads `absent` (the CONTROL —
the reading the un-paged read produced); the newer claim past the
boundary supersedes the older one inside it, and cut at 100 the
superseded carrier's `yes` is what the limb reads; a capped read is
`null`, which is neither `missing` nor `absent` nor a carrier but
`unreadable`; the ladder stops on the first short page (2 requests for
101 rows, 1 for a short thread, 2 for exactly 100 — a full page is
indistinguishable from a finished one); a page that came back unread
ends the ladder and the record says the cap was NOT what stopped it; the
input record declares and prints both ladder fields; the sibling caps
are untouched; and all three reads render ONE cap sentence.

## The census, and the triage's upgrade probe

Report-only, no state write. Over the 550 open rows (521 issues, 29 PRs)
read on 2026-09-17:

- open `pm:queue` / `pm:dispatched` cards: **274**, of which **1**
exceeds 100 comments — objectstack-ai#13799 at 117;
- all open issues over 100 comments: **8** — objectstack-ai#6015 (895), objectstack-ai#12708 (365),
objectstack-ai#6023 (241), objectstack-ai#6017 (206), objectstack-ai#6024 (187), objectstack-ai#6021 (145), objectstack-ai#6367 (127), objectstack-ai#13799
(117). Seven are `pm:seat` posts;
- open PRs over 100 comments: **0**; the longest is objectstack-ai#18638 at 10.

**The upgrade probe's result: the condition is NOT met today.** The
clause-② population is what a `--pair`/sweep derivation actually pairs,
not what carries a queue label: the sweep derived **28 pairs from 29
open PRs**, and the longest card thread among them is **14** rows
(objectstack-ai#17534). The two open PRs that mention a 100+-comment card in prose —
objectstack-ai#18786 (objectstack-ai#6015, objectstack-ai#7623) and objectstack-ai#18765 (objectstack-ai#6024) — deliver objectstack-ai#18693 and objectstack-ai#18652
respectively, both under 10 comments; driven live before and after, both
answer exit 0 with an identical pair reading. So no recorded `--pair`
verdict on this board today was taken on a truncated pool, and the
triage's p1 condition (「找到任一进入条款②认领池、评论数 > 100 的卡并驱动一次」) has no live
instance to drive. The exposure is one PR away rather than realised:
objectstack-ai#13799 is `pm:queue` at 117 and enters the population the moment a PR
delivers it.

The cost is unchanged by the ladder, measured on the same board: **64
reads for 28 pairs, before and after**, because every live thread fits
one page and the ladder stops on a short page. The live `--pair 18765`
input records differ in exactly three lines — the two request paths
gaining `&page=1`, and the two new ladder fields.

⚠️ One thing the two full sweeps do NOT compare: the sweep's finding
COUNT moved 4 → 3 between them, and that is the board, not this diff.
`needs:contract-review` was hung on PR objectstack-ai#18792 at `2026-09-17T21:04:46Z`,
between the two runs, closing the C1 split on objectstack-ai#18792 / objectstack-ai#17541 on its
own. The controlled A/B is the `--pair 18765` diff above.

## The ablation

Two legs, each from the COMMITTED fix, each proving the mutation reached
disk by blob hash and occurrence count before reading any result, each
restored under a `trap` with `git checkout HEAD --` and verified by hash
and an empty `git diff HEAD`. HEAD blob
`ccd5ad7c9a00fe703d644be261a24f1ed847915a`.

| leg | mutation | blob after | self-test |
|---|---|---|---|
| A — the ENTRY side | `COMMENT_PAGE_CAP` 10 → 1 |
`1a0a952d07748101937420006b9475042d93a5cb` | **exit 1, 11 of 865 red** —
the 101st-claim pins, the superseding pins, the request-count pins, the
input-record pin |
| B — the UNJUDGED side | the cap branch returns the pages that DID
arrive (the pre-fix fail-OPEN default) |
`c176dfe7e54e7de6bc45737487841a346f509b79` | **exit 1, 3 of 865 red** —
a capped read is no longer `null`, no longer `unreadable`, and files no
cap sentence |

Every red in both legs belongs to the new battery; nothing pre-existing
went red in either. A third, unplanned reading came for free: leg B's
first attempt was a `perl -0pi` substitution whose anchor contained a
`/`, so the edit silently did nothing — the on-disk proof refused it
with `ABLATION VOID: the edit did not reach disk` instead of reporting a
green as a measurement.

## Self-test

```
✓ check-clause2-carriers self-test: 865 cases pass
```

838 before, 865 after — the 27 the new battery registers, which is what
its floor pins.

## Derived gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, no hand-fed path list, re-derived after
each `origin/main` merge (identical list both times). All 34 run at head
`993cb89e18`, each exit code captured by redirect-then-`$?`:

```
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-changeset-no-major.mjs --self-test :: exit 0
node scripts/check-ci-filter-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test :: exit 0
node scripts/check-scripts-symbol-anchors.mjs :: exit 0
node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0
node scripts/check-self-test-wired.mjs :: exit 0
node scripts/check-self-test-wired.mjs --self-test :: exit 0
node scripts/check-self-test-workflow-commands.mjs :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0
node scripts/check-whole-set-label-write.mjs :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:bash32-floor :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:entry-guard :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:parse-guard :: exit 0
pnpm check:pm-clause2-carriers :: exit 0
pnpm check:pm-dispatch-gates :: exit 0
pnpm check:pnpm-filter-targets :: exit 0
pnpm check:ratchet-remedy-authority :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm lint :: exit 0
```

`--ran` reconciles 34 derived / 34 run / 0 UNRUN. `pnpm
check:pm-dispatch-gates` was run detached to a file — 1,788 cases,
748.6s on this box — and waited on in the foreground rather than under a
timeout, so it is a measurement and not a SIGTERM.

## Out of scope, deliberately

objectstack-ai#18764 (a decorated `**Claim:**` never enters the pool — the ENTRY side)
was read and NOT folded in: this card is WHICH rows reach the reader,
not what the reader does with them, and the two repairs touch different
lines. `claimRetractions` (PR objectstack-ai#18770, the EXIT side) was read for the
words it uses and not touched. The header's request-budget paragraph is
amended in the same commit, because it stated "2 reads per card" as a
fact and the thread is now a ladder — a cost statement that stopped
being true is the shape this file exists against.

`skip-changeset`: `scripts/pm/**` ships in no package's `files[]`, so
nothing published moves.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…t-map.map` back at it (objectstack-ai#18904)

Fixes objectstack-ai#18406

Clause-②: yes (widening) — the published accept set grows by exactly one
key, `ui/ListMapConfig:style`. Changeset: `@objectstack/spec` minor.

Director decision batch objectstack-ai#153 item 4, letter 1 (ruling comment 5724940537
on objectstack-ai#18406, maintainer 「其他同意」). ⛔ Not `mapStyle` (letter 2 declined: "a
second spelling the renderer would have to learn"). ⛔ No alias (letter 3
declined: "an alias is a permanent obligation for a key nobody has
written yet").

## The defect, measured

`ListMapConfigSchema` is a `strictObject` and `style` was the one member
of the map renderer's own documented config surface it omitted, so
`ListMapConfigSchema.safeParse({ style:
'https://tiles.example/style.json' })` answered `success: false` and a
map style could not be declared through the spec's list-view face at
all.

Measured at the `.objectui-sha` pin
`53ded82bf7a494f54e344e19099dbf00854b8694`:

| Reading | Where |
| :--- | :--- |
| `ObjectMapConfigSchema` declares eight keys, `style` among them |
`packages/types/src/zod/objectql.zod.ts:562`, the `style` row at `:570`
|
| `getMapConfig` reads `schema.mapStyle || schema.map?.style` |
`packages/plugin-map/src/ObjectMap.tsx:365` |
| the authored block is validated against that schema |
`packages/plugin-map/src/ObjectMap.tsx:373` |
| objectui's own docs publish `style` inside the block |
`content/docs/plugins/plugin-map.mdx:131` (that path is objectui's, and
the spec docblock's parenthetical is scoped to objectui — verified
present at the pin, so the citation is sound) |
| `FLAT_MAP_CONFIG_KEYS` is that shape MINUS `style` |
`packages/plugin-list/src/ListView.tsx:40-67` |

So the divergence was against the documented surface the spec docblock
itself cites, not merely against code.

⚠️ Line numbers were re-derived from the tree. The card and the ruling
both cite `view.zod.ts:1631` for `ListMapConfigSchema`; on this branch's
base it is at **`:1754`**.

## The change, file by file

**`packages/spec/src/ui/view.zod.ts`**
- `ListMapConfigSchema` (`:1754`) gains `style`, an optional string,
after `center`. The describe names it the map style URL, records that
the component-level `mapStyle` is read FIRST and wins when both are
present, and says it is not the inline CSS `style` record a component
node carries.
- The docblock's strictness paragraph (`:1746-1752`) gains a purely
ADDITIVE correction. `strictObject` stays and the rationale for closing
the block is untouched — the ruling does not fault strictness. What is
recorded is that the paragraph's parity claim was one key SHORT, which
key it was, and the pin it was measured at.

**`packages/spec/src/ui/component.zod.ts`**
- `map` (`:3272`) stops being `z.unknown()` and becomes
`ListMapConfigSchema.optional()`, spelled exactly as the sibling `gantt`
door is. `ListMapConfigSchema` joins the `./view.zod` import on line 4.
The registration `'object-map': ObjectMapPropsSchema` (`:3735`) is
unchanged.
- The "VALUE posture for `map`" paragraph is rewritten: it was the
record of WHY the door had to stay open, and that reason is now
discharged.
- The flat-guidance docblock gains "minus that same `style`" plus the
reason `style` is subtracted on both sides — flattened to the top level
it collides with `BaseSchema.style`, the node's inline CSS record, which
is why the renderer stopped reading a top-level `style` as a map style
at all (objectui#5017) and why the prescription routes it to `mapStyle`.
- `mapStyle` is untouched. It is the component-level spelling, read
first, and is not a member of the config block.

**`packages/spec/src/ui/component.test.ts` — PR objectstack-ai#18403's negative pins,
inverted (`:3534-3540`)**
- `ListMapConfigSchema.safeParse({ style: ... }).success` flips `false`
→ `true`.
- The door's acceptance of a `map` block carrying `style` stays `true`,
now through the spec's own schema rather than through an open value.
- ⭐ A third assertion is added, because the two above would pass just as
well against the `z.unknown()` they replaced: an authored `map` block
carrying `styl` is refused, and the refusal is asserted to land AT `map`
with `styl` echoed in the issue's `keys`.
- The flat-key-set equality (`:3507-3509`) now derives by SUBTRACTION —
the config block's shape minus `style` — rather than by hand-listing, so
a newly declared config key still lands in that assertion.

**`packages/spec/src/ui/view.test.ts`**
- The documented-surface test carries eight keys instead of seven, and
asserts the parse OUTPUT equals the input, so a declaration that
silently dropped a member could not satisfy it.
- A new pin takes the card's repro as its subject: `style` accepted
alone, the node-level object form of `style` refused, and both `styl`
and `mapStyle` still refused — `style` did not open the block.

**Generated (regenerated, never hand-edited)**
- `packages/spec/authorable-surface/ui.json` gains exactly one line,
`ui/ListMapConfig:style`. Written by `pnpm --filter @objectstack/spec
build`.
- `content/docs/references/ui/view.mdx` and
`content/docs/references/ui/component.mdx` via `gen:docs`. In the
component reference the `object-map.map` row changes from `any` to the
block's real shape and a nested-shape table for it appears — the
retraction of `z.unknown()` made visible in the published reference.
- `authorable-surface.base.json` is deliberately NOT touched; only
`gen:authorable-surface-base` writes it.

Of the 15 generated artefacts, `check:generated` proved exactly one
stale (`content/docs/references/**`) and `--fix` regenerated only that
one. After the `origin/main` merge and a full rebuild, all 15 report up
to date with a clean tree.

## Reverse verification

**Ablation (PLANT mode, `scripts/ablation-replace.mjs`).** The `style`
row was renamed to `styleAblated18406`, so the key is no longer declared
under the name the pins assert. On-disk evidence: anchor count 1 → 0,
planted marker 0 → 1, blob `1c83516951e3` → `d256ada1f791`. Restore
proved by blob equal to HEAD, `git diff HEAD` empty, and a WHOLE-TREE
`git status --porcelain` empty.

- **Source leg — RED, as predicted.** 4 of 782 tests fail, and they are
exactly the four this PR authors or modifies; 778 pass. So the new pins
are discriminating, not vacuously true.
- **Build leg — an independent third witness.** `gen:schema`'s
authorable-key ratchet also reds under the ablation and names
`ui/ListMapConfig:style` as having "disappeared from the contract" while
the committed baseline still records it. That is mechanical confirmation
that the key really landed in the tracked contract.
- **Dist leg — NOT MEASURED, and declared so.** Because the ablated
build failed at `gen:schema`, no ablated `dist/` was ever produced. The
temporary type probe consequently read the PRE-mutation artefact and
exited 0 — precisely the false green
`scripts/ablation-dist-preflight.mjs` exists to catch, and it caught it,
reporting the marker ABSENT from `dist/`. That green is discarded, not
recorded as a pass.

**Cross-package type reverse verification**, supplied separately because
the dist leg above could not supply it. A temporary probe inside
`packages/lint` resolves `@objectstack/spec/ui` through the package
`exports`, i.e. the BUILT declarations, so its verdict is a function of
build state:

- Positive leg, exit 0 — `style` is assignable on the config block, AND
a `ts-expect-error` directive on an undeclared member inside `map` is
USED, which is only true if the door really narrowed away from
`unknown`.
- Negative leg, exit 2 — both halves fail as required, and each would
have COMPILED against the pre-change declarations. The compiler names
`bogus` as not existing in the block type and prints that type inline
with its `style` member, and it reports the stale expectation-of-refusal
as an unused directive. A cached declaration file cannot produce either
error.

Both probe files were deleted; the tree is clean.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived **107** command families from the real change set (8
paths, including the generated artefacts and tests). All 107 were run,
each exit code captured BEFORE any pipe and written to a file, and
`--ran FILE` reconciles to **107 derived / 107 run / 0 NOT-MEASURED / 0
UNRUN** — a DERIVED zero, since every line carries its code and none of
them is 3.

Eight families first returned a non-zero that was NOT a finding and are
green after their prerequisite was met: five reported `PREREQUISITE NOT
MET` for build state (cleared by a full `pnpm build`), one needed the
`MANIFEST` value CI supplies and was then run in CI's exact spelling,
and one — `check-plugin-teardown-shape.mjs --self-test` — refused
because its fixture commit is unreachable in a shallow clone, cleared by
a targeted `git fetch origin 621a487...` rather than an unshallow.

⛔ Not read as clearance: 13 of the roster families run only the
checker's own `--self-test`, 49 declare a tracked-file roster whose
silence is a fact about a list, 11 declare a population too wide to
place, and 6 CI jobs scheduled by these paths run steps no local command
covers. Those are CI's.

## Lint and tests

Both readings taken at `0af4dfc76`, the final commit.

- **`pnpm lint`** = `eslint . --no-inline-config`, run repo-wide rather
than narrowed: exit 0, **6853 files inspected** (counted from `--format
json`), 0 errors, 0 warnings, 75s. For the record, the config never
enables type-aware linting for any file — no `parserOptions.project` and
no typed rules — which `eslint.config.mjs:326-336` states with its own
positive control, so this diff could not move an untouched file's
verdict either way.
- **`pnpm --filter @objectstack/spec test`** — 488 files, 14128 tests,
all passing.
- **`pnpm --filter @objectstack/spec typecheck`** — green, test layer
included.
- **`check:generated`** — all 15 artefacts up to date.

Verify-lock readings, for the lane's baseline: acquisitions waited 0s /
5s / 0s / 4m05s / 1m18s / 0s, and the full `pnpm build` held the lock
4m56s.

## Acceptance notes

Observed, in scope of nothing here, and ⛔ not filed by this seat.

1. **The docblock clause "so an extra key here would be dropped there"
is measurably false at the pin**, and it is the clause immediately
beside the one this PR corrects. `ObjectMapConfigSchema` is a plain
`z.object`, not strict, so a `safeParse` of a block carrying an extra
key SUCCEEDS; `getMapConfig` uses that result only to decide whether to
emit a `console.warn`; and `ObjectMap.tsx:378` returns a spread of the
authored block, so the extra key is carried through rather than dropped.
An undeclared key inside `map` is therefore neither flagged nor dropped
by the renderer — the opposite of what the clause asserts. Left
untouched deliberately: the ruling's facts reach the `style` key and
objectui's read of it, and they do not reach this mechanism claim, so
correcting it would widen the diff past what the ruling authorises.
Passed to the seat for filing-class judgement rather than filed.

2. The retraction did **not** need to reach into PR objectstack-ai#18638's hunk
regions. Its `view.zod.ts` hunks start at old lines 53 / 2241 / 2771 /
2978 and its `component.zod.ts` hunks at 6 / 738 / 1591 / 1601; this
PR's regions are `view.zod.ts:1727-1766` and `component.zod.ts:4,
3163-3175, 3210-3218, 3272-3273`. The `origin/main` merge brought only
`content/docs/deployment/validating-metadata.mdx` and
`scripts/gen-sdui-manifest-node.mjs`, neither of which overlaps.

## 维护者速读(草稿)

### 改了什么

地图视图配置块 `ListMapConfigSchema` 新增一个可选键 `style` —— 地图底图样式的 URL。同时把组件契约里
`object-map` 的 `map` 属性重新指回这个块(上一个 PR
因为这个键缺失,被迫把它放成了"任意值")。就一个键,没有别名,没有第二种拼写。

### 为什么改

渲染器一直在读这个键,规格却一直不声明它。后果是:作者在平台的权威声明面上**根本无法声明地图样式** —— 写 `style`
被严格校验拒掉,写 `mapStyle` 规格也不认。代价已经付过一次:为了绕开这个拒收,上一个 PR
把一个组件属性的值契约整个放开成了"任意值",于是那个位置的拼写错误不再有人拦。这次两件事一起收回。

键名取 `style` 而不是 `mapStyle`,因为 Mapbox 与 MapLibre 这个行业本来就叫
`style`,也是渲染器在配置块里实际读的名字。

### 风险与代价(含回滚)

- **接受集只扩大,不缩小**:原先能通过的元数据,现在全部照旧通过。
- **一处收紧**:`object-map` 的 `map`
值从"任意值"变回真正的块契约,所以块内的拼写错误现在会被明确拒收并指名。这正是本次要恢复的保护,但它确实是一个行为变化 ——
如果线上有依赖"任意值"往 `map` 里塞非契约键的写法,会在这里被拒。仓内扫描未发现这类写法。
- **不涉及 objectui 仓**:objectui 那边教人写 `mapStyle` 的开发警告,由另一张 `domain:ui`
卡单独修,本 PR 不碰。
- **回滚**:单次 revert 即可。没有数据迁移,没有退役键,没有 ADR-0087 转换,没有墓碑。

### 席位意见

### 你要做的

本 PR 是 `Clause-②:
yes`(公开接受集扩大),按流程需要合约层评审后才可落地;评审席位与载体标签不由本席位挂摘。除评审外无需人工动作 ——
本地门禁已全绿,生成产物已按机制重生成。

---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_

Co-authored-by: Claude <noreply@anthropic.com>
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
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…semantics (ADR-0136) (objectstack-ai#18985)

Fixes objectstack-ai#17778

Clause-②: no

The `domain:spec` half of the maintainer ruling on objectui#8069
(decision batch objectstack-ai#119 item 3, 2026-09-12: 「同意」 to **A**, with **Q2 yes**
and **Q3 yes**), **reworked** under decision batch objectstack-ai#160 item 1 on objectstack-ai#19003
(letter **A**, maintainer 「同意」 2026-09-18T11:58Z). The renderer half is
objectui#8069 and is not in this PR.

## What this PR is now — a record, one producer fix, and nothing else

Ruling A removed the schema change from this PR. Decision batch objectstack-ai#122
item 2 (card objectstack-ai#15811, comment
[`5644350409`](objectstack-ai#15811 (comment)),
2026-09-12) had already ruled the evaluated-slot narrowing across **all
36 declaring positions** — its own census names *「field / option /
grid-column `visibleWhen` / `readonlyWhen` / `requiredWhen`」* — and **PR
objectstack-ai#18638 owns it under one ADR-0087 id and lands first**. Card objectstack-ai#17778
ruled fault semantics, not the carrier symbol, so nothing ruled is lost.

**Removed here** (commit `9f30a18a9`): the three `FieldSchema`
triad-slot edits; the `PredicateSchema` / `PredicateInputSchema`
rebinding, which go back to composing the persistence schemas, wide; the
`field-rule-predicate-evaluated-slot-source-required` ADR-0087 entry
(the entry file and, through `gen:migration-registry`, its registry
rows) and the changeset marker; the triad pin test; the two ADR anchors;
and every api-surface / reference-page row that existed only because of
those.

**Kept, per the same ruling**: ADR-0137 (the fault-semantics record,
renumbered — see below), the ADR-0089 pointer addendum, and the producer
fix.

**Re-derived and removed** (see the measurement below): the ADR-0058 D7
roster entry.

### The revert is byte-exact, not "close enough"

The three sources and the `field.zod` anchor were restored with `git
checkout d8b12fc --` (the merged-main parent of this branch's merge
commit, a pinned sha, not a moving ref); `git diff d8b12fc` is
**empty** for each. `registry.ts` was not hand-edited — the entry FILE
was deleted and `gen:migration-registry` re-emitted the generated
regions; the result is byte-identical to merged main and
`check:migration-registry` is green.

After regenerating, the whole diff against merged main is **6 files**,
and the generated half of it is **4 lines**:

| artefact | delta vs merged main |
|---|---|
| `content/docs/references/**` | **byte-identical** — 0 rows |
| `packages/spec/api-surface-declarations/` | **4 lines**, in `root.txt`
and `shared.txt`, all of them the producer fix |
| `packages/spec/api-surface/`, `export-origins/`, `declaration-map/` |
unchanged — `check:api-surface` green |

## The producer fix, on its own terms

`cel()` and `expression()` (hence `F` and `P`) always write a non-blank
`source`, but were declared as returning `Expression`, whose `source` is
optional — a declaration of a shape neither function can produce. The
fix is at the PRODUCER (Prime Directive objectstack-ai#12): the return type now states
what the helper emits. The docblock was **rewritten** so it no longer
rests on the triad requirement this rework removes; what it now says is
the general fact plus the live consumer, `FlowEdgeSchema.condition`.

```diff
-declare function cel(strings: TemplateStringsArray, ...values: unknown[]): Expression;
+declare function cel(strings: TemplateStringsArray, ...values: unknown[]): EvaluatedExpression;
-declare function expression(source: string, dialect?: ExpressionDialect, meta?: ExpressionMeta): Expression;
+declare function expression(source: string, dialect?: ExpressionDialect, meta?: ExpressionMeta): EvaluatedExpression;
```

Narrowing a return type removes nothing from a caller —
`EvaluatedExpression` is assignable to `Expression` — so no call site
changes.

## `Clause-②` re-judged, and the changeset level follows it

The previous body declared `Clause-②: yes (narrowing)`. With the
accept-set narrowing gone that declared something this diff no longer
does, and `Check Changeset` read it and reddened. **Re-judged against
the six files that remain: `Clause-②: no`.** The judgement is measured,
not asserted:

| question the declaration asks | reading | instrument |
|---|---|---|
| does it widen an accept set? | **no** — no zod schema moves at all;
`packages/spec/src` changes are two return types and two docblocks |
`git diff d8b12fc -- packages/spec/src` |
| does it narrow an accept set? | **no** — same reading; the narrowing
left with commit `9f30a18a9` | as above |
| does it expand the public surface? | **no** — no export added, removed
or renamed | `check:api-surface` green, with no removed-or-narrowed
report |

⇒ the changeset is **`patch`**, the BREAKING banner is gone and so is
the ADR-0087 disposition marker — both belonged to the narrowing that
owned them. Something published still moves (two return types in the
shipped `.d.ts`), so `skip-changeset` would be wrong; a producer-side
fix in a released package is exactly what a `patch` entry is for.

Re-run locally against a `pull_request` payload carrying this body,
before pushing: `GITHUB_EVENT_NAME=pull_request node
scripts/check-changeset-no-major.mjs --base origin/main --event
PAYLOAD_PATH` → **exit 0**, `NOT DECLARED — the clause-② declaration
reads 'no'`. The same command against the OLD body reproduces the CI
red, so the local run is a measurement and not a hopeful one.

## ADR-0136 is renumbered to ADR-0137

PR objectstack-ai#18480 added `docs/adr/0136-declared-journeys-as-priority-anchor.md`
about 42 hours earlier. `scripts/check-adr-anchors.mjs` prescribes
exactly this — the NEW record takes the next free number, and
renumbering an already-accepted record was ruled out, so before it is
referenced is the only cheap moment.

**`0137` re-verified free on this rework**, not inherited from the
earlier sweep:

| query | result |
|---|---|
| `git ls-tree origin/main docs/adr/` | tops out at **0135** — neither
0136 nor 0137 on `main` |
| added `docs/adr/*` files across **all 31 open PRs** (`GET
/pulls/{n}/files`) | two hits, both `0136`: objectstack-ai#18480 and this PR. **Zero**
on 0137 |

The scan lit twice on `0136`, so the zero on `0137` is a reading and not
a dead query.

## Three corrections the record owed

**1. Its Status line claimed an implementation it no longer has.** It
now says what is true: this record declares and implements nothing. D1's
authoring refusal is batch objectstack-ai#122 item 2's, carried by objectstack-ai#18638; D2 / D3 /
D4 are consumer-delivered in objectui#8069.

**2. The gate-slot conversion is RULED and IN FLIGHT, not "filed as a
follow-up".** The dangling sentence is gone. The record's claim that
converting the gate slots "would bake a direction the ruling did not
give" is true of batch **objectstack-ai#119** and was silent about batch **objectstack-ai#122 item
2**, which gave exactly that direction six days earlier, and about
objectstack-ai#18638 which implements it. The claim is now stated as what it is — a
statement about which ruling authorizes what, not a reason the
conversion should wait — and the lint-side cost
(`validate-visibility-predicates.ts`'s `celRefusal` records the opposite
position today) is named as a cost objectstack-ai#18638 carries, not as an objection.

**3. The hand enumeration is replaced by a citation of objectstack-ai#15811's census,
because the hand list had already rotted.** It omitted
`packages/spec/src/system/settings-manifest.zod.ts:424` and `:686`, both
`visible: SettingsVisibilityInputSchema`, which is
`ExpressionInputSchema.superRefine(...)`. Measured through
`SettingsManifestSchema.safeParse` on the built `dist`:

| authored `visible` | manifest slot (`:686`) | specifier slot (`:424`)
|
|---|---|---|
| `{ dialect: 'cel', ast: … }` | ACCEPTED | ACCEPTED |
| `{ dialect: 'cel', source: '   ' }` | ACCEPTED | ACCEPTED |
| bare `'   '` | ACCEPTED | ACCEPTED |
| **LIT CONTROL** `'data.provider.toUpperCase()'` | **REFUSED**
`custom@visible` | **REFUSED** `custom@specifiers.0.visible` |
| DARK CONTROL `"data.provider === 'smtp'"` | ACCEPTED | — |

The refinement is live at both slots and narrows neither the `ast`-only
nor the blank-`source` arm — `if (!source) return;` is the line, and the
lit control is what makes the six ACCEPTEDs a reading.

## The ADR-0058 D7 roster entry — re-derived, then removed

The ruling's KEEP list names it, and carries NO re-derive clause. The
instruction to re-derive came from this seat's dispatch brief, not from
the maintainer — recorded here because the earlier wording attributed it
to the ruling. The re-derivation concluded the entry no longer belongs
(measured: discovery finds 37 positions with the line present and 37
without, floor 37 unchanged and met at 37; the alias types zero slots),
so the line is removed here pending the maintainer's explicit
confirmation of that removal.

The roster lists schemas that **declare** an expression surface — "a
slot whose accepted grammar is narrower gets its own schema and must be
listed here too". `PredicateInputSchema` earned its place only while the
rebinding made it `= EvaluatedExpressionInputSchema` and bound the triad
to it from another file. Reverted, it is a plain alias of
`ExpressionInputSchema` typing no slot, and the ledger header's
limitation 2 names it as the standing **latent** example — leaving it
rostered would make that paragraph false. objectstack-ai#18638 measured the same thing
independently: *「`PredicateInputSchema` is a plain alias of
`ExpressionInputSchema` with zero slot users; it stays wide with the
schema it aliases」*.

Measured twice on this branch with the revert already applied, by
raising the `head` floor to 9999 through `ablation-replace.mjs` so the
assertion prints the count:

```
roster entry PRESENT  -> discovery found 37 position(s) via 'head'   (floor 37)
roster entry ABSENT   -> discovery found 37 position(s) via 'head'   (floor 37)
```

Identical, because the three triad positions are head-matched by
`ExpressionInputSchema` again. Both mutations landed and both restores
verified on disk (`anchor 1 -> 0`, then `blob == HEAD` and `git diff
HEAD` empty). Identity grep agrees: `PredicateInputSchema` has **2**
hits under `packages/spec/src/**/*.zod.ts` — its own definition and its
`z.input` companion, zero slots — against a lit control of **19** files
for `ExpressionInputSchema` and a dark control of **0**.

⛔ **Not a gate weakening.** The `head` floor stays **37** and is met at
37; no ledger row is deleted, no floor is lowered, no test is skipped or
quarantined. The same three surfaces are discovered through the schema
that types them. Both dogfood files are byte-identical to merged main.
The same statement holds for the two other removals: the triad pin test
and the ADR-0087 entry are removed because the behaviour they recorded
is no longer in this PR — not to turn anything green.
`packages/qa/dogfood` re-run after the removal: **7 passed (7)**.

## ⛔ GOVERNED SURFACE — this PR parks as a draft, by design

`docs/adr/**` is on the register, so this is the regime's correct
resting state, not a stall. An authorized approval is owed before any
seat lands this. This seat has not flipped it ready, has not enqueued it
and has not armed auto-merge. The `needs:contract-review` carrier is the
review seat's and stays hung; a fresh at-tier review is owed on this
head.

## Evidence

- `pnpm --filter @objectstack/spec check:generated` — proved exactly **2
of 16** artifacts stale (`api-surface-declarations/`,
`content/docs/references/**`) and `--fix` regenerated only those two.
Re-run after: **16 of 16** up to date.
- `check:migration-registry` green with the entry file deleted — the
generated regions match `entries/`.
- Gate families re-derived on this head with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` and reconciled with `--ran`; results and
every non-zero exit are in the report comment on objectstack-ai#17778.
- ⚠️ The derivation printed a **STALE TREE** warning: this branch is at
least 14 commits behind `origin/main` and 10 of the files the families
are derived from moved in that range. No `origin/main` merge was taken
in this rework, on purpose — this PR rebases after objectstack-ai#18638 lands, and a
merge now would move the `merge=os-regen` artefacts for a base that is
about to change. So the family list is this tree's, and CI on the merge
queue's rebuilt generation is what covers the rest.

## Acceptance notes

- **`packages/spec/src/data/field.zod.ts:1513` `expression`** (the
formula slot) is an evaluated slot on `ExpressionInputSchema`.
Untouched, and no longer this PR's business at all: it is inside
objectstack-ai#18638's 36-position census.
- **`PredicateSchema` / `PredicateInputSchema` have zero slot users** —
measured above. The ruling says they stay as they are and that a later
card may retire them as dead symbols on their own measurement. No anchor
is left claiming otherwise: the anchor this PR added for
`shared/expression.zod.ts` is deleted, and the `field.zod.ts` anchor is
back to its pre-PR text.
- The ADR-0137 record's `Consumers` line still names
`shared/expression.zod.ts` and `data/field.zod.ts`. That is the set of
files the DECISION governs, which is unchanged; it is not a claim that
this PR edits them for that reason.

## 维护者速读(草稿)

> 席位意见一节留空,由席位在 at-tier 评审后定稿为评论。

**改了什么** — 按 batch objectstack-ai#160 item 1 的裁决 A,把这个 PR
里的**协议收窄整段拿掉**:字段三条规则槽位(`visibleWhen` / `readonlyWhen` /
`requiredWhen`)回到原样,`Predicate*` 两个别名回到原样(仍然是宽的持久化契约),对应的 ADR-0087
迁移条目、pin 测试、两个 ADR anchor、以及只因它们才产生的 api-surface
与参考文档行,全部删除。留下的是:**ADR-0137**(改号后的 fault 语义记录)、ADR-0089 的指针附录、`cel` /
`expression` 的返回类型修复。另外按指示重新推导后,删掉了 ADR-0058 D7 的那一行 roster 条目。

**为什么改** — 同一个收窄早在六天前就被 batch objectstack-ai#122 item 2 裁决过了,覆盖全部 36
个求值槽位(包含本卡的三条字段规则),并且由 PR objectstack-ai#18638 用**一个** ADR-0087 id 承载、先落地。两个 PR
各带一份收窄,就是一次迁移两个 id、两份 CHANGELOG 说法。objectstack-ai#17778 裁决的是 fault
语义,不是承载它的符号,所以记录留下、收窄交出去,**没有任何被裁决过的东西丢失**。

**风险与代价(含回滚)** — 风险很低:协议行为**零变化**(没有任何 zod schema 移动),对外只剩两个函数返回类型收窄,而
`EvaluatedExpression` 可赋值给 `Expression`,所有调用点照常编译。changeset 因此从 `minor`
+ BREAKING 降为 **`patch`**,`Clause-②` 重判为 **`no`**(三项读数在上表)。代价是本 PR
不再自带任何强制:D1 的编写期拒收要等 objectstack-ai#18638;这一点在记录的 Status 和 Scope boundary
里明写了,不是留给读者去发现。回滚成本极低——本轮全部是删除与还原,恢复即 revert 这四个 commit。

**席位意见** — (留空)

**你要做的** — 这个 PR 碰了 `docs/adr/**`,属受管面,停在 draft
等一个授权批准,这是制度的正常终态。需要你看的是三件事:① ADR-0137 现在**只声明不实现**——D1 交给
objectstack-ai#18638、D2/D3/D4 交给 objectui#8069,这个归属你是否认可;② 记录里原来那句「gate
槽位转换会写进裁决没给的方向」已改写为「那只对 batch objectstack-ai#119 成立;batch objectstack-ai#122 item 2 给了这个方向,objectstack-ai#18638
正在做」,这个更正你是否同意;③ D7 roster 那一行**被删**而不是保留——测量是:删与不删,`head` 发现数都是 37(地板
37),且 `PredicateInputSchema`
零槽位使用,所以它不再是「更窄的别名」。裁决原文把它列在「保留」里,**且未附任何重新推导的条款** ——
要求重新推导的是本席派发令,不是维护者(先前措辞把它归给了裁决,此处更正)。推导结论是该条目不再属于花名册(实测:该行在与不在,发现数都是
37,地板 37 未动且 met at 37;该别名零槽位),故此处删除,**等您明确确认这一删除**。

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants