Skip to content

fix(spec)!: refuse a padded groupByField on kanban, gantt and timeline instead of handing the renderer a lookup that always misses - #18695

Merged
os-bill merged 5 commits into
mainfrom
claude/issue-17499-groupbyfield-padded-name
Sep 17, 2026
Merged

os-bill merged 5 commits into
mainfrom
claude/issue-17499-groupbyfield-padded-name

Conversation

@os-bill

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

Copy link
Copy Markdown
Collaborator

Fixes #17499
Clause-②: no

What changed

KanbanConfigSchema.groupByField (required), GanttConfigSchema.groupByField and TimelineConfigSchema.groupByField were bare z.string(), so ' stage' was valid authored metadata. All three now carry the non-padded pattern this repo already chose for the same defect on the sibling axis — grouping.fields[].field, landed as f8e5790593 (#17360 / PR #17498), which scoped this axis out by name.

The refusal is addressed to the offending key (kanban.groupByField, gantt.groupByField, timeline.groupByField), quotes the offending spelling so the invisible whitespace is visible, and carries the name to write instead.

Reusing the precedent, and the three places it does not fit

  • The regex is the precedent's, not a second one. Its module-private constant is renamed GROUPING_FIELD_NON_PADDED_PATTERN to NON_PADDED_FIELD_NAME_PATTERN and now serves both axes. No exported symbol moves (check:api-surface is clean with no regeneration).
  • The kanban key is REQUIRED — the precedent's is not. That is the whole reason this is its own card: a padded value there cannot be withdrawn by omitting the key, so a .trim() producer would be the author's only feedback channel and it would say nothing. The refusal is sharper here, not milder.
  • Refuse, not .trim() — the precedent's stated reason, re-checked and unchanged: a trimming schema makes ' stage' and 'stage' silently equivalent, the consumer-tolerance direction AGENTS.md #0.1 refuses. None of these three rows honestly wants the other answer, and the required key is the strongest case against it.
  • No ruling citation in the message. The precedent's message ends with its ruling date; ruling C on objectui#7347 is about grouping.fields[].field and explicitly not about this axis, so quoting it here would attribute a decision nobody made. The code comment says so.

ADR-0087 disposition: registered, and the entry is in this PR

The changeset carries an adr-0087: registered ui-list-view-groupbyfield-padded-refused marker (the HTML-comment form the gate reads), and packages/spec/src/migrations/entries/semantic/18.ui-list-view-groupbyfield-padded-refused.ts registers it under protocol major 18, with the regeneration lap committed beside it.

Why registered and not a not-required category, measured against the gate's closed vocabulary:

category verdict here
unpublished refused — @objectstack/spec publishes to npm.
already-registered false. The precedent's entry scopes itself to grouping.fields[].field and says in its own text that the groupByField axis is not touched by it.
no-migration-prescription refused — the changeset body carries a FROM/TO block, and there genuinely is a prescription: stored views with a padded name are refused on their next authoring-path save and must be re-authored.
runtime-interface-only refused — this is a spec schema, not a runtime TS interface.
type-surface-only refused — the narrowing is a parse-time refusal, not a type-only change.

The same-defect precedent took registered with a new semantic entry, for the same stored-metadata reason.

The regeneration lap, and why two of its three files show no diff. pnpm --filter @objectstack/spec gen:migration-registry inserted the entry into registry.ts's generated region (56 lines, insertion-only, comment run carried through; nothing hand-edited inside the markers). gen:spec-changes and gen:upgrade-guide both ran and wrote byte-identical files: each projects majors up to PROTOCOL_VERSION, which is 17.0.0, and this entry registers under 18. The control is the landed sibling f8e5790593 — its file list touched neither projection either. check:migration-registry, check:spec-changes and check:upgrade-guide are all green.

Evidence

Red before green — ablation, on-disk proof, hash-verified restore. The fix was committed first; the mutation removed the three superRefine calls from packages/spec/src/ui/view.zod.ts (the subject resolves through the same-package relative import ./view.zod, so there is no dist leg to prove — the test never resolves through exports).

BEFORE   HEAD blob 6ff7ba320a8fa445fa34e421b39716359194bc1b == on-disk hash; 3 occurrences
MUTATED  on-disk hash bf9424b852ac545affc8d69abc8548e1114817da; 0 occurrences; git diff --stat: 3 deletions
ABLATED  24 failed | 40 passed   (exit 1)
RESTORE  git checkout HEAD -- packages/spec/src/ui/view.zod.ts; on-disk hash back to 6ff7ba32…, git status clean
GREEN    64 passed | 387 skipped (exit 0)

The direction was predicted before the run and observed as predicted: turning red, 24 of the 64 arms. The 40 that stayed green are the LIT and no-trim arms, which is what makes the 24 a reading rather than a restatement.

⭐ LIT — the narrowing does not over-reach. Every distinct groupByField spelling this repo carries still parses, on all three schemas: harvested across every .ts / .tsx / .mdx / .json / .mjs outside node_modules, 14 distinct literals, zero of them padded. owner.name is the load-bearing member and is pinned — these keys hold a field reference, not a machine name, which is why the snake_case grammar /^[a-z_][a-z0-9_]*$/ is the wrong vocabulary (packages/lint's validate-list-view-field-refs.test.ts carries kanban: { groupByField: 'owner.name' } in a case asserting no findings; that suite is green here, 137 tests). Three of the 14 — 'warning', 'error', a bracketed select_or_status_field placeholder — are a severity-map value and prose in a completeness hint, so they are not authored names and are not pinned.

⭐ DARK — three readings that must be 0, before and after.

reading before after
other bare z.string() keys in view.zod.ts reached by this change 0 0 — the edit touches exactly the 3 groupByField declarations; git diff shows 3 changed property sites and no other schema key. A per-schema test probes every sibling string key (summarizeField, titleField, startDateField, endDateField, progressField, colorField) with a padded value and requires it to still parse.
authorable-surface/ rows moved 0 0 — check:authorable-surface green with no regeneration; a value-level refusal adds no key.
api-surface/ entries moved 0 0 — check:api-surface green with no regeneration; the published type surface is byte-unchanged.

Which gate can actually go red on this — measured, not guessed. check:docs reads these three describe() strings: it exited 1 naming content/docs/references/ui/view.mdx and content/docs/references/ui/component.mdx before gen:schema && gen:docs, and 0 after. check:adr-0087-registration reads the changeset and exited 1 before the ledger entry landed, naming exactly the missing id, and 0 after it — red before green on that gate too. The remaining 13 of the 15 spec artifact gates were green throughout — they structurally cannot see a value-level refine, and are recorded as DARK rather than claimed as coverage.

Commands run. The schema and test blobs have not moved since the first lap (view.zod.ts is 6ff7ba320a8fa445fa34e421b39716359194bc1b at both heads and on disk, view.test.ts is 162772c6…), so the ablation above stands unrepeated; every other reading below was re-taken at ca0dd6ce0e after the ledger entry landed:

command result
pnpm --filter @objectstack/spec test 485 files / 13932 passed
pnpm --filter @objectstack/spec typecheck exit 0 (test layer compiles; debt ledger held)
pnpm --filter @objectstack/spec check:generated all 15 artifacts up to date (both laps)
pnpm --filter @objectstack/lint exec vitest run src/validate-list-view-field-refs.test.ts src/runtime-gate.view-writes.test.ts 137 passed
pnpm exec eslint . --no-inline-config 6819 files, 0 errors, 0 warnings — the whole population at ca0dd6ce0e, no narrowing claimed; both files this lap added or changed are provably in the run
pnpm check:nul-bytes 8804 files, no raw control bytes
pnpm check:empty-changeset, 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 … registered ui-list-view-groupbyfield-padded-refused (new here)

Acceptance notes

  • The empty string still parses on all three keys, deliberately: this card narrows padding only, and widening the pattern to catch '' would be a second, undeclared narrowing riding on it. On the required kanban key a blank name is a blank board, refused one layer down by the field-reference rules, not by this pattern.
  • Noted, not filed — objectui's packages/i18n carries groupByField: 'Group by field' / '分组字段'. Those are i18n label entries keyed by the same name, not values of this spec key, so they are neither affected nor evidence about accepted values. Recorded because a reader harvesting the sibling repo will hit them.
  • Noted, not filed — packages/spec/src/kernel/functional-completeness.ts documents the kanban fallback chain as groupBy = groupByField || groupField || ..., and groupField is a live legacy alias declared in objectui, not in this repo. Nothing here is wrong; it is the context a future reader of this key needs. Carrier: whoever next touches the kanban completeness hint.

Authored by the domain:spec execution seat, session session_01JbZnqu8bt6YqfJsr9vaFb3.


Generated by Claude Code

WIP checkpoint before the verification run.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
LIT (every in-tree spelling still parses, `owner.name` included), DARK
(every sibling string key on the same schema still accepts a padded
value), the no-trim discriminator and the required-key double door.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
…padded `groupByField`

Generated projection of the three `describe()` changes. `gen:schema` +
`gen:docs`; no hand edit.

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

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 11 documentable anchor(s).

⛔ 2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via GanttConfigSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via GanttConfigSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 7 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 — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1bc22b3dcddc8a30b4826da8625e7787d5518a8f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1bc22b3dcddc8a30b4826da8625e7787d5518a8f

⚠️ 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 1bc22b3dcddc8a30b4826da8625e7787d5518a8f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…pByField` refusal

`ui-list-view-groupbyfield-padded-refused` under protocol major 18 — the
disposition the changeset's marker already claimed. One new file under
`entries/semantic/` plus its regeneration lap; nothing hand-edited inside
`registry.ts`'s generated markers.

`spec-changes.json` and `docs/protocol-upgrade-guide.md` were regenerated
and are byte-identical: both project majors up to `PROTOCOL_VERSION`
(17.0.0), and this entry registers under 18. The landed sibling
`f8e5790593` is the control — it touched neither file either.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@os-bill
os-bill marked this pull request as ready for review September 17, 2026 15:50
@os-bill
os-bill enabled auto-merge September 17, 2026 15:50
@os-bill
os-bill added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 12bb672 Sep 17, 2026
44 checks passed
@os-bill
os-bill deleted the claude/issue-17499-groupbyfield-padded-name branch September 17, 2026 16:17
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] groupByField accepts a padded field name on Kanban (required), Timeline and Gantt — the sibling axis #17360 scoped out

2 participants