…ban lane, and is refused by name
`ListView`'s kanban branch destructured
`columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the merged
kanban config and spread the REST *after* its own `groupBy: laneField`, so a
`groupBy` surviving in the bag OVERRODE the lane the branch had just resolved.
Measured on the card's distinguishing fixture, not reasoned:
`options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' }` against
`kanban = { groupByField: 'LANE_FROM_CANONICAL' }` produced
`node.groupBy === 'LANE_FROM_STRAY_GROUPBY'`. It read as latent only because the
one producer that fed it wrote both spellings with the same value by
construction, so no fixture driven by that producer could tell them apart.
Maintainer ruling of 2026-09-12 (decision batch #117 item 5, verbatim
「8365 同意」) — option B. Option A (strip and re-group in silence) was the
fallback for a measured zero of stored views and is NOT taken. Two halves:
1. `groupBy` joins the destructure, so the CANONICAL lane wins.
2. The key is refused LOUDLY at the read door of the view. This repo's
`.passthrough()` `KanbanConfig` mirror declares it as a named alias refusal
(`aliasKeyRefusal`) pointing at `groupByField`, in the sentence shape the
protocol already answers the sibling alias with. The refusal lands wherever a
view's metadata is validated — `os check` / `os validate`, the VS Code
extension — and on the type face, where the inferred authoring surface now
carries `groupBy?: never` and `tsc` refuses the key at the authoring site.
Both nestings are covered, and the second is load-bearing rather than thorough:
`ListView` merges `{ ...options.kanban, ...kanban }`, and the producer
objectui#8213 retired wrote into `options.kanban`, so that is where the stored
views this ruling is about carry the key. `options` is
`z.record(z.string(), z.any())` and can declare no member, so it takes the
declared arm's guidance as a check (`custom` at `options.kanban.groupBy`) while
the declared slot reports the arm itself (`invalid_type` at `kanban.groupBy`).
Two codes, ONE message — read off the arm's own `.description`, and pinned equal.
Honouring `groupBy` as a declared alias was never available: `@objectstack/spec`'s
`KanbanConfigSchema` is a strict object of `columns` / `groupByField` /
`summarizeField` and refuses it by name. Re-measured on the version this tree
pins (17.4.0; the card measured 17.3.0) with both controls firing — a lit control
(`zzzBogusKey`) is named, a dark control (`groupByField` alone) draws no
`unrecognized_keys`. Legalising it is a spec change on its own objectstack card,
never a renderer-side widening (AGENTS.md #0.1).
`groupBy` on the generated `object-kanban` NODE is untouched: that is the live
canonical lane key `ObjectKanban` reads. The `.passthrough()` is kept — an
undeclared sibling key still rides through, pinned as a control.
Two now-false prose claims in `app-shell` are corrected in the same commit:
`ObjectView.tsx` and `ObjectView.kanbanGroupByRetired-8213.test.tsx` both stated
that the override "is untouched" and "is a `plugin-list` change on its own card".
Comment-only; no behaviour in that package changes.
Part of objectui#8365
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Co-authored-by: Claude <noreply@anthropic.com>
Part of #8365
Implemented by the
domain:uios-devseat, sessionsession_01UzHd6hDYatoDn17BuwKxnZ(recorded in prose as well as in the footer, because a body edit downgrades the footer's session form).What this repairs
ListView's kanban branch destructuredcolumns/groupByField/groupField/cardFields/titleFieldout of the merged kanban config and spread the rest after its owngroupBy: laneField. AgroupBysurviving in that bag therefore overrode the lane the branch had just resolved.Measured on the card's distinguishing fixture, not reasoned:
It read as latent only because the single producer that fed it wrote both spellings with the same value by construction, so no fixture driven by that producer could tell the two apart.
The ruling this implements — option B, not re-opened
Maintainer ruling of 2026-09-12, decision batch #117 item 5, verbatim 「8365 同意」. Option A (strip the key and re-group in silence) was the fallback for a measured zero of stored views and is not taken. Honouring
groupByas a declared alias was never on the table.groupByjoins the destructure inpackages/plugin-list/src/ListView.tsx, so the stray key can no longer reach the passthrough spread.groupByField.Where the refusal is — the part the ruling flagged, answered explicitly
The read door is the view-level
KanbanConfigmirror,packages/types/src/zod/objectql.zod.ts. The key is now a declared, unwritable member (aliasKeyRefusal, the helper this repo already uses for thechartTypealias), so it is refused instead of riding the object's.passthrough()in silence. The message is the sentence@objectstack/spec's ownstrictObject({ aliases })answers with, quoted down to its surface noun:That door is where the author of the view is standing — every channel that validates a view's metadata reaches it:
os check/os validate(safeValidateSchema),tscat the authoring site:z.never()at a member position makesz.inputof that memberundefined, so the inferred authoring face carriesgroupBy?: neverand the key is a compile error before anything runs.⛔ It is deliberately not a
console.warn. The ruling names that outcome and refuses it; this repo's two existing warn-only diagnostics (columnSpellingDiagnostics.ts,dataTableBindDiagnostic.ts) both record that a rendered in-view message would need user-facing copy in all ten locale packs — which is the one thing this card must not do, since that is objectui#9169 / objectui#9170's surface and the ruling's note 4 says this card shares no file with them. It does not: see the file list.Both nestings, and why the second one is load-bearing rather than thorough
ListViewmerges{ ...schema.options?.kanban, ...schema.kanban }before it reads anything, and the producer objectui#8213 retired wrote intooptions.kanban— so that is where the stored views this ruling is about carry the key.optionsis declaredz.record(z.string(), z.any())and can declare no member at all, so it takes the same guidance string (read off the declared arm's own.description) as a check.codekanban.groupByaliasKeyRefusalarminvalid_typekanban.groupByoptions.kanban.groupBycustomoptions.kanban.groupByTwo codes, one message — pinned equal, so the remedy cannot fork into two dialects. Refusing only the declared nesting would have left exactly the affected population silently re-grouped, i.e. option A wearing option B's name.
Scope kept narrow, deliberately
.passthrough()is kept. One named arm was declared; the object was not closed. An undeclared sibling key still rides through — pinned as a control, because a later.strict()would satisfy every other assertion in the file.optionsis judged. That bag isz.any()for every view kind and stays that way.groupByon the generatedobject-kanbannode is untouched — that is the live canonical lane keyObjectKanbanreads. Only the view-level config spelling is refused.kanban.groupFieldstill resolves the lane, pinned as a control.The pin, and the two ablations that prove it bites
packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsxcarries the card's fixture and asserts both halves off the same document — the lane through the renderer (a registry spy on the generatedobject-kanbannode), the refusal through the published read door. 11 tests.Controls, each with a stated failure it would catch: a dark control (canonical config parses green), a passthrough control (an undeclared sibling key still parses green), two protocol controls (the spec refuses
groupByby name with lit and dark controls firing; the spec still does not declare it), and a lane control (groupByalone no longer sets the lane — the arm that tells option B apart from "strip it and hope").packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.tscarries the compile-time half, because vitest erases it and a green vitest run says nothing about it. Read throughtsc, never vitest.Protocol re-measurement (the card measured 17.3.0; this tree pins 17.4.0), both controls firing:
Note the protocol's arrow is U+2192, not the ASCII
->the ruling comment transcribed; this PR matches the protocol's own bytes.Gate results
Every reading below is from
d6edbaecb, i.e. afterorigin/mainwas merged in — the merge moved the head, so every family was re-run on it rather than quoted from the pre-merge run. Each verdict is the gate's own printed line; exit codes were captured by redirect-then-capture, never through a pipe.Typecheck (and the build closure it waits on) —
pnpm exec turbo run type-check --filter=@object-ui/types --filter=@object-ui/plugin-list --filter=@object-ui/app-shell --concurrency=2⭐ This is the gate that reads the compile-time pin:
@object-ui/types'type-checkistsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json, and the third pass is the only thing in this repo that compilessrc/__tests__/**.Tests —
pnpm exec vitest run packages/types/ packages/plugin-list/plus the two app-shell kanban pins andcore'snormalize-list-view(repo-root invocation, per AGENTS.md — notpnpm --filter, which changesprocess.cwd())Ablations — three legs, each read through the gate that actually enforces its claim. Every leg: mutate, prove the mutation landed on disk by anchor count, run, restore, prove the restore by blob hash against the HEAD blob.
groupByarm fromKanbanConfigtsc -p tsconfig.test.jsonerror TS2578: Unused '@ts-expect-error' directive.at the pin1 failed, 10 passed: only the declared-nesting armgroupByfromListView's destructure3 failed, 8 passed: the three lane arms, naming the stray laneThe asymmetry is the point: leg 3 reddens the lane arms and leaves every refusal arm green, leg 2 reddens a refusal arm and leaves every lane arm green. Neither half is carrying the other.
optionscheck stays green, because it reads the guidance off the arm's.descriptionconst, which the mutation left standing. That is an honest reading of two independent mechanisms sharing one string, and it is why leg 1 exists: the member arm's own enforcement istsc, and vitest can only see half of it.Ablation legs read source, not
dist: the root vitest config aliases every package specifier to itssrc, andpackages/types/tsconfig.test.jsonreaches the mirror by relative import. So there is nodistrung in the resolution path to go stale, and none was built for the ablation.Repo gates — each exit 0:
eslint — the full root union was run, so this is a measurement rather than a declared narrowing:⚠️ Stated precisely: this root-level invocation is a superset of what CI's
pnpm exec eslint --no-inline-config --format json .atd6edbaecbresolved 4863 files from eslint's own config and reported 95 errors / 12984 warnings. 0 of those errors are in any of the 7 files this PR touches (they sit in 79 unrelated files and are pre-existing).pnpm lint(turbo run lint, i.e. per-packageeslint .) resolves, not the same invocation.NOT MEASURED, named rather than implied:
pnpm lintin CI's own per-package form, and the repo-widecheck:*farm inci.yml— CI owns those runs.Acceptance notes
Reported rather than migrated, as the dispatch asked. Real authored usages of a view-level
kanban.groupByfound by a structural scan of every tracked file (baregroupByinside akanbanobject literal; lit controlgroupByFieldin the same position returned 51 sites, so the small reading is a reading):packages/data-objectstack/src/updateView.draft.test.ts:38— a stale metadata payload,kanban: { columns: [...], groupBy: 'status' }, in a test about draft ADDRESSING that never validates the body. Not migrated here: it is another package's fixture and nothing gates it. It is the one the objectui#8213 census already named.packages/plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx:121,160— the two arms that pin the capability gate's behaviour for a bag carrying the key. Unaffected: that gate never readgroupBy, and both arms stay green.Zero in docs, examples, seed data or any runtime producer.
noted, not filed:
packages/plugin-view/src/ObjectView.tsx(itsgenerateViewSchemakanban branch, around line 1361) has the same defect shape: it destructurescolumns/groupByField/groupField/titleField/conditionalFormattingand spreads...restKanbanafter its owngroupBy, so a straygroupByinviewOptions.kanbanoverrides the lane there too. That is the SECOND route toObjectKanban(the authoredobject-viewelement, which never passes throughListView). Left alone here because it is a different package and a different renderer, i.e. new validation surface outside this card's fence — but the contract half of this PR now refuses the key for both routes, so the residual is a behaviour gap, not a contract gap. Handing it to the PM to file rather than filing it unsearched: the dedup sweep for it was not run.ListViewSchema.optionsisz.record(z.string(), z.any()), so no per-view-type config refusal reachesoptions.KINDfor any kind — kanban is covered here only because this PR adds a key-scoped check. Every other view kind's config is unvalidated in that nesting. Observation, not a defect on this card.@objectstack/specdoes not resolve from an objectui checkout. It does:node_modules/@objectstack/specis present at 17.4.0 and every measurement above was taken from this worktree.Generated by Claude Code