Skip to content

fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) - #9021

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-8990-kanban-groupby-optional
Sep 10, 2026
Merged

fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990)#9021
os-warren merged 4 commits into
mainfrom
claude/issue-8990-kanban-groupby-optional

Conversation

@os-warren

@os-warren os-warren commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8990

@objectstack/spec declares groupBy: z.string().optional() on ObjectKanbanPropsSchema; both published objectui faces required it. objectui was therefore narrower than the protocol on a published key — it refused an object-kanban document the protocol accepts, and that document could not be annotated with its own type. Direction per the maintainer principle in force (recorded verbatim, untranslated): 「我们的项目以 objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。」

This is a widening. Nothing that validated before stops validating. An authored groupBy is still declared and still enforced (a non-string lane key is refused exactly as before); only its absence is newly admitted. Every flipped pin gained a CONTROL so that optional cannot decay into unjudged.

Rework after contract review (VERDICT: REWORK, record)

R1, blocking — the published docs contradicted both faces. Four hand-written sites still asserted the pre-change contract, and two of them named this very card as the pending change: the PR closed the card while leaving the docs telling readers it was still open. AGENTS.md #2 ("Not done until docs reflect the code") is binding, and no CI gate catches this class — only review does. All four now state optional and the measured lane-less behaviour, and both "tracked as objectui#8990" sentences are gone:

site was now
content/docs/api/schema-reference.md property table "Required." optional; notes a lane-less board holds no cards
content/docs/api/schema-reference.md columns callout arm "inert on this block", "groupBy is required here", "tracked as objectui#8990" arm applies to a lane-less board and is live there; raw strings vs picklist labels; zero cards
content/docs/plugins/plugin-kanban.mdx callout same claim + tracking sentence same correction
packages/plugin-kanban/README.md "type and groupBy are required" + inline // required record source required, groupBy optional but what makes lanes hold cards

⚠️ I also corrected two further README sites the review did not name (:48, :109) that made the same requiredness claim, and added a line to the schema-reference record-source paragraph making explicit that the record-source rule is separate from the lane key and unaffected — that refusal survives and is the easiest thing to conflate with this change.

Two wording alignments (non-blocking). The unqualified "what made the arm live" in the columns docblock and in the pin file's header now carry the same schema-valid qualifier the groupBy docblock already had, because the renderer never consulted this package's validator: SchemaRenderer runs core's structural validateSchema, which has no kanban rule. The arm was always live for a document that reached it unvalidated; what requiredness prevented was a document being valid and getting there.

Evidence corrected — one of my two corpus citations was overstated. The review measured that the data-objectstack.mdx fragment is still refused after this PR, at RECORD_SOURCE_REQUIRED (dataSource is not a rung of the record-source ladder), and my own #7780 pin asserts exactly that. So it is evidence that a lane-less board is a documented authoring, not a document this PR admits. I have downgraded it in the docblock and the changeset and marked the limit explicitly. The conclusion is unchanged because the ListView evidence carries it alone — and that one is stronger than I had it: objectDef loads asynchronously, so laneField is undefined on every load until it lands, and stays undefined whenever detectStatusField finds no stageField role and no field named status/stage/state/phase, or when stageField: false suppresses detection outright (ADR-0085). I re-derived both claims against record-semantics.ts and ListView.tsx:2563 rather than restating the review.

Other: "can only NARROW #8993's reachable set" corrected to UNCHANGED / not widened (admitting documents cannot shrink a reachable set); the six rotted line addresses are no longer restated even as history.

The protocol is right, so no upstream card was needed

The dispatch required an @objectstack/spec card first if I concluded the protocol was wrong to make this key optional. I concluded it is correct, on this repository's own evidence: ListView.tsx generates a lane-less node the renderer serves and both faces refused, and a lane-less board is a documented authoring shape. Same shape as objectName (objectui#7780): a key the renderer guards at every read, declared REQUIRED, refusing boards that render today.

What a lane-less board actually does — measured, not argued

A widening's risk is a runtime path that never ran, so I rendered them through SchemaRenderer with a real dataSource:

document result
no groupBy, no columns effectiveColumns falls past all three schema.groupBy && guards, returns [] — empty board, no crash
no groupBy, bare-string columns lanes are drawn, titled by the RAW strings — the arm this card unlocks for schema-valid documents
no groupBy, any columns ZERO cards, always
card moves on a lane-less board inert — both move paths open if (!groupBy) return

bucketCardsIntoColumns opens with if (!data || !groupBy || !Array.isArray(data)) return columns.map(...), so with no lane key records are never distributed. A lane-less board is lane headings, not a populated board — which is exactly what the corrected docs now tell authors.

objectui#8993 is not widened, and is not touched

The double-bucketing lives after the bucketer's !groupBy early return, and the lanes taking id: opt.value straight from picklist options are built under if (schema.groupBy && ...) — a branch a lane-less board cannot enter. Asserted both ways in the pin file.

Verification

All on final head fa93c7fc2, fresh clone.

run result
@object-ui/types test 168 files / 3341 passed
@object-ui/types type-check (incl. tsconfig.test.json) exit 0, 0 errors
@object-ui/plugin-kanban test 48 files / 276 passed
block-config.test.ts 43 passed
type-check plugin-kanban / plugin-list / plugin-view / app-shell exit 0, 0 errors each (round 1, code unchanged since)
check:doc-snippets 638 of 638 block(s) judged, 0 failed — after the scoped 35-package build it requires
check:doc-types Every documented component type is registered
check:doc-fences OK, 227 documents
check:control-bytes OK (scanned 7212 tracked text file(s))
check:new-line-citations 0 new citation(s), enforcement report-only -> exit 0
check:changeset-presence 6 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)
check:governed-queue-guard --test NOT GOVERNED — none of 6 paths matched

⚠️ check:doc-snippets first returned PRECONDITION NOT MET (exit 2) — "the packages it resolves against are not built". That is not measured, not red; it is reported here only after the scoped build it prescribes.

Ablation — now with the tsc leg the first round lacked

The review correctly found my first ablation had no tsc leg, so the four TS-face pins went unmeasured. Re-run with both legs (both faces reverted to REQUIRED, source only, trap restoring from HEAD):

baseline (head) tsc      -> exit 0, 0 errors
on-disk proof            -> TS removed=0 injected=1 | ZOD removed=0 injected=1
ablated vitest           -> exit 1, Test Files 4 failed, Tests 9 failed | 89 passed (98)
ablated tsc              -> exit 2, 4 errors
  7322:147 TS2344 _GroupByIsString
  7322:153 TS2344 _GroupByIsOptional
  7322:181 TS2741 lanelessLiteral
  7780:164 TS2741 LANELESS_BOARD_NOW_ACCEPTED

Both legs now light, and the vitest count matches the review's independent run exactly (its 8/47 over three files, plus the block-config failure my set adds). Restore leg: git diff HEAD empty for both faces.

⚠️ A process failure worth recording: I ran that ablation on an uncommitted tree, so its restore-from-HEAD leg silently reverted my own in-progress edits to objectql.ts along with the mutation. git status caught it; the edits were re-applied and committed before re-verifying. The measurements were unaffected (they tested the committed faces), but the rule is: commit first, because the restore leg points at HEAD.

Acceptance notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w


Generated by Claude Code

…eclares it (objectui#8990)

`@objectstack/spec` declares `groupBy: z.string().optional()` on
`ObjectKanbanPropsSchema`; both published objectui faces required it. objectui
was therefore narrower than the protocol on a published key — it refused an
`object-kanban` document the protocol accepts, and that document could not be
annotated with its own type.

This is a widening. An authored `groupBy` is still declared and still enforced
(a non-string lane key is refused exactly as before); only its absence is newly
admitted. A CONTROL beside each flipped pin keeps "optional" from decaying into
"undeclared".

The requiredness was refuted by this repo's own corpus, not only by the spec.
objectui#7780 recorded two producers excluded from objectui#7322's measurement
and both are still live: the `{ type, dataSource }` board documented in
`content/docs/utilities/data-objectstack.mdx`, and the node `ListView.tsx`
generates as `groupBy: laneField` where `laneField` ends in `|| undefined`.

Measured, not assumed — every `schema.groupBy` read is a guarded early-return,
so a lane-less board degrades rather than breaking: no `columns` renders an
empty board; bare-string `columns` draws those lanes titled by the raw strings;
card moves are inert. Every lane-less board holds ZERO cards, because
`bucketCardsIntoColumns` returns before distributing records when there is no
lane key.

Side effect: the protocol's bare-string `columns` arm, admitted by objectui#8913
and recorded there as unreachable, is now reachable — it fires only under
`if (!schema.groupBy)`. Pinned with a firing control that distinguishes the raw
option VALUES the string arm draws from the picklist LABELS a grouped board
draws.

objectui#8993 is NOT reached from the lane-less path and is not touched here:
the double-bucketing lives after the bucketer's `!groupBy` early return, and the
picklist-materialised lanes are built under `if (schema.groupBy && ...)`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
…ner pin (objectui#8990)

Two consequences of making `ObjectKanbanSchema.groupBy` optional, both measured:

`block-config.test.ts` asserted that the node the pre-fix designer panel emitted
is refused TWICE — missing `groupBy` and name-retired `groupField`. The first
half is gone: an absent lane key is no longer a refusal. The falsification is
kept on the half that was always sharper (the tombstone, refused BY NAME) and
now asserts it as the SOLE issue, which is strictly tighter than the old
two-key set. A CONTROL is added beside it so "optional" cannot be mistaken for
"unjudged".

`laneLessBoard-8990.test.tsx` records which of its own legs guard the widening.
Ablation (source-only mutation reverting both faces to REQUIRED, dist marker
read both ways and unchanged) turns exactly the CONTRACT legs red and leaves
every RENDER leg green — because `SchemaRenderer` runs the structural
`validateSchema`, never this package's zod mirror. So the renderer's bare-string
branch was always live for a document that reached it without passing the
published validator; what the requiredness prevented was a SCHEMA-VALID document
getting there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
…ne (objectui#8990)

The docblock this replaces cited six line numbers into `ObjectKanban.tsx`; all
six had rotted (the same reads now sit ~370 lines lower), which is how this card
found them while re-deriving its own anchors. Naming the reads by symbol —
the `effectiveColumns` memo's four branches, `persistCardMove`, the
`handleCardMove` callback — keeps the census exact and stops it rotting again,
and matches what `check:new-cross-file-line-citations` says in its own words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.3 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-Bhk7wwgO.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.20KB 114.67KB
core (index.js) 7.95KB 3.19KB
create-plugin (index.js) 28.21KB 9.54KB
data-objectstack (index.js) 207.32KB 57.37KB
fields (index.js) 247.01KB 62.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.63KB 19.99KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 130.98KB 34.54KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 253.71KB 65.78KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 211.36KB 57.45KB
plugin-kanban (index.js) 46.03KB 14.30KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Contract review

PR #9021 · head fe330a7768b009bd0e568322bf7e4cea11250e22 (re-read at posting; unchanged) · base bbf068db10dd8b5fc12128b6fab1fde573bdf4ea · merge-base 2596b1b8552a7411eb001b80756e38c92ba83997 · Clause-②: yes, needs:contract-review on both carriers.

Tier fuse. CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:10505 @ objectstack 634f23de). This seat's transcript, grepped for every harness model stamp: 120 × claude-fable-5-1, one distinct value, no fallback event. Reading = constant.

Rig. Own clones under the session scratchpad (objectui at head; objectstack blobless at origin/main 634f23de), pnpm install --frozen-lockfile --ignore-scripts, ⛔ no dist/ built anywhere in the clone — every measurement below is source-only by construction. Shared checkouts untouched.

CI, read to terminal state at posting: 34 check runs, all completed; 31 success + 3 skipped (dependabot, Test (coverage), Test (coverage shard)); 0 failures.

Premise re-verified on the protocol itself: packages/spec/src/ui/component.zod.ts has groupBy: z.string().optional() on ObjectKanbanPropsSchema at the card's sha eabdd66f (:2669) and at objectstack origin/main 634f23de today (:2752).

① Derived judgments

1. "The renderer never runs the zod mirror" — CORRECT. packages/react/src/SchemaRenderer.tsx:1391 is __DEV__ ? validateSchemaOnce(schema) : { valid: true, messages: [] }; validateSchemaOnce (:71/:84) calls core validateSchema (packages/core/src/validation/schema-validator.ts:506), which contains zero kanban/groupBy rules and no zod. Nothing under packages/react/src imports @object-ui/types/zod. The published union's only non-test runtime callers are packages/cli/src/commands/check.ts:137 and validate.ts:59. ListView.tsx:2580-2582 states the same independently. ⇒ the dispatch sentence "making groupBy optional is what makes that arm reachable" holds only for documents that pass the published validator; the dev's refinement is right. Measured, not just read: in a clone with no dist/ at all, re-requiring groupBy (ablation A, below) reddens exactly the contract legs and no render leg.
⚠️ The PR still carries the unqualified claim in two places: the columns docblock in packages/types/src/objectql.ts ("objectui#8990 made groupBy OPTIONAL, which is what made the arm live") and the header of laneLessBoard-8990.test.tsx ("Making groupBy optional is what makes it live") — the same test file retracts it ~40 lines down, and the groupBy docblock carries the correct "no schema-valid document" qualifier. Not a fake reason (retracted in-file), but the two sentences must match the qualified one.

2. "The protocol is right, on this repo's corpus" — CORRECT, with one weakening the PR body glosses.

3. "#8993 is not widened" — CORRECT. packages/plugin-kanban/src/index.tsx:61 if (!data || !groupBy || !Array.isArray(data)) return columns.map(…) sits above the knownIds / __uncolumned__ sweep. In effectiveColumns (ObjectKanban.tsx:962-1018): id: opt.value is under if (schema.groupBy && objectDef?.fields?.[schema.groupBy]?.options) (:994), id: String(g) under if (schema.groupBy && effectiveData.length > 0) (:1009), the bare-string branch yields string ids, and effectiveSchema spreads ...schema so groupBy reaches KanbanRenderer → the bucketer unchanged. A lane-less board cannot reach the sweep. Wording: admitting documents cannot "narrow" a reachable set — the correct claim is unchanged / not widened; the containment itself is right.

4. "Every flipped pin gained a CONTROL" — MEASURED, in the direction the dev did not run. Ablation B (the groupBy member deleted from both faces, source only): vitest 5 failed / 93 passed of 98 — 7322 CONTROL — optional does not mean unjudged ✗, 7322 refuses a wrong-typed groupBy (42) AT the key ✗, 7322 groupBy is a member of the mirror shape ✗, 7780 .shape … both optional ✗, block-config CONTROL — … still TYPED ✗; tsc -p packages/types/tsconfig.test.json 3 errors_GroupByIsString (:147), _GroupByIsNotAny (:148), _GroupByIsOptional (:153). laneLessBoard-8990.test.tsx stays 8/8 green in this direction, exactly as its header says (it pins renderer behaviour, not the declaration). Every control lights. Restore leg: 0 dirty lines, head unchanged.

5. "The red came from source; tsconfig.test.json's empty paths is the type-check path" — CORRECT, and now shown on a tree that could not have read an artifact. Root vitest.config.mts:501-502 aliases @object-ui/types and @object-ui/types/zod to packages/types/src (every @object-ui/* alias there resolves to src); the two types pins import ../objectql / ../zod/objectql.zod relatively; packages/types/tsconfig.test.json "paths": {} is tsc-only (its own comment: so @objectstack/spec resolves through the workspace dependency). Ablation A (both faces back to REQUIRED, source only) in my no-dist clone reproduced the dev's numbers exactly — 8 failed / 47 passed on the same three files (1 + 6 + 1) — plus 1 more in block-config.test.ts, which the dev's ablation set did not include. ⇒ vitest reads source; the dispatch's "empty paths ⇒ a skipped rebuild measures the old artifact" was wrong for the vitest leg. The gap is in the dev's run, not the claim: it had no tsc leg, so the four TS-face pins went unmeasured. Measured here under A: 4 errors — 7322 :147 _GroupByIsString, :153 _GroupByIsOptional, :181 TS2741 lanelessLiteral; 7780 :164 TS2741 LANELESS_BOARD_NOW_ACCEPTED. Head baseline before either mutation: vitest 55/55 (+43 block-config), tsc 0 errors — the dev's figures.

② Semver

.changeset/8990-object-kanban-groupby-optional.md declares '@object-ui/types': minor. Precedent on the same schema, same package, same direction: .changeset/7780-object-kanban-record-source.md (objectName REQUIRED → presence rule) = minor; .changeset/8913-object-kanban-columns-declared.md = minor, with the level reasoned. scripts/check-changeset-no-major.mjs forbids major outright (one fixed group pinned to @objectstack's major; AGENTS.md §版本号策略 :247-249), so minor is the repo's ceiling for its own contract moves. A widening of a published accept set at minor matches the repo's practice; proportionate. Nit only: unlike #8913 the body does not say why minor rather than patch (#7780's did not either).

③ Boundary flags

  • Citation gate. The seat's framing is right, and it is measured rather than argued. scripts/cross-file-line-citation-census.mjs:569-576: syntax 5 (a continuation :NNN) is recorded only while lastAddress is set, and lastAddress is set only by a syntax-1..4 hit — each of which carries its own line number — within the previous 2 lines; a bare path mention never sets it. The rotted form was a bare path on one line and (:601, :625, :640) on the next. scanFile on the merge-base objectql.ts: hits citing ObjectKanban.tsx or any of the six numbers = 0 (1 hit in the whole file); on head: 0. The gate itself, run against the merge-base in my clone: 0 new citation(s), 7 files scanned. The gate's charter (check-new-cross-file-line-citations.mjs:33-35, quoting the ruling) claims 「卡面测过的四种语法 + 无文件名的续写地址」. ⇒ this sub-form is inside the ruling's population and outside the scanner's: that is silence about a class, not report-only about it — enforcement mode is orthogonal to parse coverage, so "the gate already owns this class and is deliberately report-only" does not hold. Worth a card, on the gate (scanner vs charter), not on this PR; it is not a blocker here. Side note: the head docblock re-writes the six stale numbers as a historical quote and the scanner sees 0 there too; acceptable because the prose says they rotted, but the rework could drop the literal numbers.
  • isBoardEmpty. KanbanImpl.tsx:882 confirmed, pre-existing and groupBy-blind; one sentence: this widening makes a schema-valid { type, objectName } board reach a region with no lanes, no cards, no empty state and no diagnostic (core validateSchema has no kanban rule) — a silent blank for an os check-clean document — so it should be written onto finding(plugin-kanban): chunk 加载与数据 commit 是两个无序竞态 —— objectui#8534 关掉了镜子那一半,React.lazy 这一半仍然开着 #8827 or filed, not only "noted".
  • availableViews. ListView.tsx:2202-2209 reads only the four view-level groupByField/groupField spellings and :2277-2280 adds schema.viewType unconditionally; neither reads the node schema; the widening offers kanban nowhere new. Correct.

⛔ The finding that decides the verdict — the published docs now contradict both faces

AGENTS.md :95: "#2 — Docs-driven. For every feature/refactor, update package README.md and content/docs/guide/*.md. Not done until docs reflect the code." Untouched by this PR, all at head fe330a77, all hand-written (no generator names them):

  • content/docs/api/schema-reference.md:935| groupBy | string | **Required.** …
  • content/docs/api/schema-reference.md:950 — "The bare-string array is accepted but inert on this blockgroupBy is required here … The requiredness of groupBy is tracked as objectui#8990."
  • content/docs/plugins/plugin-kanban.mdx:100-102 — "groupBy is required on object-kanban, so on this block the strings are always ignored … tracked as objectui#8990."
  • packages/plugin-kanban/README.md:126 "type and groupBy are required", :139 "// required — the field that makes the lanes".

Two of these name this very card as the pending change; the PR closes the card and leaves them asserting the pre-PR contract and an arm that is now live as "inert". Under the principle the PR itself invokes — 「文档应该以实际实现为准」 — and AGENTS.md #2, this is not done. No gate catches it (Doc Snippet Type Check type-checks snippets; there is no docs-vs-face parity check). Blocking; the fix is four sites plus deleting the two "tracked as objectui#8990" sentences.

Independence

Implemented-by: claude/issue-8990-kanban-groupby-optional (mode:subagent)
Reviewed-by: session_01Jmxdo7bmeqCQHLSfmLVX9w · subagent agent-ad117683565c4bfb0 (mode:subagent, harness model claude-fable-5-1)

SELF-REVIEW. Both lines resolve to the same dispatch session session_01Jmxdo7bmeqCQHLSfmLVX9w; the implementer and this reviewer are sibling subagents of it. Under contract-review.md 「两者同 session ⇒ 报 SELF-REVIEW,⛔ 不作独立复核」 this record is not an independent review: it is the seat's in-session clause-② check at tier, with the dispatch order's conclusions deliberately not taken as inputs (every judgment above was re-derived from the card, the PR and the tree, and two of the dispatch's own claims are refuted above). It does not satisfy an independence requirement if one is imposed downstream.

Owed before re-review

  1. Update the four doc/README sites above to say optional and to describe the measured lane-less behaviour (lanes drawn from bare strings, zero cards, moves inert); delete both "tracked as objectui#8990" sentences.
  2. Align the two unqualified "what made the arm live" sentences (objectql.ts columns docblock; laneLessBoard-8990.test.tsx header) with the schema-valid qualifier the groupBy docblock already carries.
  3. Optional: "narrow" → "not widened" in the bug(plugin-kanban): bucketCardsIntoColumns double-buckets every record when a lane id is not a string — knownIds holds raw ids while the group keys are always strings #8993 paragraphs (docblock, pin header, changeset).

Everything else — the accept-set direction, the renderer mechanism, the controls in both directions, the #8993 containment, the changeset level — passes as measured.

VERDICT: REWORK

What I did not measure


Generated by Claude Code

…ract (objectui#8990)

Contract review R1 (blocking): four hand-written doc sites still asserted the
pre-change contract, and two of them named THIS card as the pending fix — so the
PR closed the card while leaving the docs telling readers it was still open.
AGENTS.md #2 ("Not done until docs reflect the code") is binding and no CI gate
catches this class.

- content/docs/api/schema-reference.md — the property table said "Required.";
  the columns callout said the bare-string arm is "inert on this block" because
  "groupBy is required here", and closed with "tracked as objectui#8990".
- content/docs/plugins/plugin-kanban.mdx — same claim, same tracking sentence.
- packages/plugin-kanban/README.md — "type and groupBy are required" and the
  inline "// required" comment, plus two further spots the review did not name
  that made the same claim.

All now state optional AND the measured lane-less behaviour: lanes drawn from
bare strings and titled by the RAW values (a grouped board shows picklist
LABELS), zero cards, moves inert. Both "tracked as objectui#8990" sentences are
deleted. The record-source rule is called out as separate and unaffected, since
that refusal survives and is easy to conflate with the lane key.

Also from the review:

- Two unqualified sentences ("what made the arm live") now carry the
  schema-valid qualifier the groupBy docblock already had. The renderer never
  consulted this package's validator, so the arm was always live for a document
  that reached it unvalidated; what requiredness prevented was a document being
  valid AND getting there.
- The mdx corpus evidence is DOWNGRADED where it was overstated: that fragment
  is still refused after this change, at RECORD_SOURCE_REQUIRED, so it shows a
  lane-less board is a documented authoring, not a document this change admits.
  The ListView evidence carries the argument alone and is strengthened to what
  was actually measured: objectDef loads async, so laneField is undefined on
  every load until it lands, and stays undefined when detectStatusField finds no
  stageField role and no status/stage/state/phase field, or when stageField is
  false (ADR-0085 suppression).
- "can only NARROW #8993's reachable set" corrected to UNCHANGED / not widened.
- The six rotted line addresses are no longer restated even as history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3489.9 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-Bn4Jbo1s.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.20KB 114.67KB
core (index.js) 7.95KB 3.19KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 207.56KB 57.44KB
fields (index.js) 247.01KB 62.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.64KB 19.98KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 130.98KB 34.54KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.44KB 65.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 211.36KB 57.45KB
plugin-kanban (index.js) 46.03KB 14.30KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Contract review

PR #9021 · round 2 · head fa93c7fc259025528afee61e5fe9ead3926ac3dc (re-read at posting; unchanged) · base main · merge-base 2596b1b8552a7411eb001b80756e38c92ba83997 · card #8990 · Clause-②: yes · needs:contract-review on both carriers, left in place · PR is draft, mergeable_state: clean.

Charter read at objectstack origin/main cac62d5f9de14f1d332c0bdb3b3630bec62b6e39: contract-review.md (60 lines), landing-operations.md, true-green.md, platform-readings.md, SKILL.md 〈入队与落地〉.

Tier fuse. CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:10507 @ cac62d5f). This reviewer's own sidechain transcript (agent-a6f4bd6d929085ed0.jsonl), grepped for every harness-stamped per-message model field at posting: 95/95 × claude-fable-5-1, one distinct value, no fallback stamp. Reading = constant.

Rig. Own clones under the session scratchpad: objectui blobless, checked out at the head; objectstack sparse at origin/main. pnpm install --frozen-lockfile --ignore-scripts; ⛔ no dist/ built anywhere in the clone (packages/types/dist: 0 files throughout), so every vitest reading below is source-only through the root config's @object-ui/*src aliases, and every tsc reading is packages/types/tsconfig.test.json. Shared checkouts untouched. Installed protocol: @objectstack/spec@17.4.0 (lockfile), whose dist/ui/index.mjs:12890 carries groupBy: z.string().optional(); the source at objectstack origin/main packages/spec/src/ui/component.zod.ts:2752 says the same.

CI on this head, read to terminal state at posting: 35 check runs, all completed: 32 success + 3 skipped (dependabot, Test (coverage), Test (coverage shard)), 0 failure, 0 in progress; plus 1 commit status (Vercel) success. All checks, not a required subset.

Round 1 (5623901848, head fe330a77, verdict REWORK) owed three items. Whether each was delivered is measured under ① on the head, not taken from the PR body.

① Derived judgments

1. ObjectKanbanSchema.groupBy REQUIRED → OPTIONAL on both published faces — CORRECT, and the code is unchanged since round 1. packages/types/src/objectql.ts:3025 groupBy?: string; and packages/types/src/zod/objectql.zod.ts:1286 groupBy: z.string().optional(); merge-base control objectql.ts:2938 groupBy: string; / objectql.zod.ts:1271 z.string(). git diff fe330a77 fa93c7fc -- <both faces>: the TS face's hunks are all inside the groupBy / columns docblocks; the zod face is untouched by the rework commit. Direction: toward the protocol (ObjectKanbanPropsSchema.groupBy optional at the installed 17.4.0 and at origin/main), so the change removes a narrowing rather than adding one — the only direction the maintainer principle permits without a spec card. Measured on the head, not read: baseline vitest over the four pin files 98/98, tsc -p tsconfig.test.json 0 errors.

  • Ablation A (both faces back to REQUIRED, source only, no dist): vitest 9 failed / 89 passedlaneLessBoard-8990 ×1 (the contract leg only; every render leg stays green), block-config ×1, 7322 ×1, 7780 ×6; tsc 4 errors7322.test.ts:147 _GroupByIsString, :153 _GroupByIsOptional, :181 TS2741 lanelessLiteral, 7780.test.ts:164 TS2741 LANELESS_BOARD_NOW_ACCEPTED. Identical to the dev's round-2 figures and to round 1's.
  • Ablation B (member deleted from both faces): vitest 5 failed / 93 passed — the three CONTROL/enforcement legs in 7322, .shape in 7780, the panel CONTROL in block-config; tsc 3 errors (:147, :148 _GroupByIsNotAny, :153). laneLessBoard-8990 stays 8/8 green in this direction, exactly as its header says it will. Every control lights in both directions. Restore leg after each: git status --porcelain = 0 lines, head unchanged.

2. Round-1 item 1 (blocking): the four published doc sites — DELIVERED, plus two more. Head vs merge-base, grepped:

  • content/docs/api/schema-reference.md property row: merge-base **Required.** → head "Optional since objectui#8990 … holds no cards". Columns callout: "inert on this block … groupBy is required here … tracked as objectui#8990" → head "applies only to a board with no groupBy … raw strings … no cards … dragging a card writes nothing back". The record-source paragraph gains the "separate and unaffected" sentence.
  • content/docs/plugins/plugin-kanban.mdx callout: same rewrite; tracking sentence gone.
  • packages/plugin-kanban/README.md merge-base :126 / :139 fixed as owed, and :48 / :109 (not named by round 1) fixed too.
  • git grep "tracked as objectui#8990" on the head: 0 hits under content/ and packages/ (merge-base: 2). The one survivor is .changeset/8913-object-kanban-columns-declared.md:65-69, another card's pending record — see the note under ②.
  • Every behavioural sentence the new docs make is true of the head renderer: the bare-string arm fires only under if (!schema.groupBy) (packages/plugin-kanban/src/ObjectKanban.tsx:980-987); a grouped board's lanes are titled by opt.label (:1004); no groupBy + no columnsreturn [] (:1017); zero cards on any lane-less board (packages/plugin-kanban/src/index.tsx:61 early return before distribution); moves inert (persistCardMove :1127 and handleCardMove :1244 both open if (!groupBy…) return). Pinned by laneLessBoard-8990.test.tsx (8 legs, each with a grouped control), which I ran.

3. Round-1 item 2: the two unqualified "what made the arm live" sentences — DELIVERED. The objectql.ts columns docblock now reads "reachable BY A SCHEMA-VALID DOCUMENT" with a new qualifier paragraph naming SchemaRenderer → core validateSchema (confirmed on the head: packages/react/src/SchemaRenderer.tsx:1391; packages/core/src/validation/schema-validator.ts has no kanban / groupBy rule); the pin file's header carries the same qualifier. git grep -i "made the arm live\|makes it live" on the head: 0 hits.

4. Round-1 item 3 (optional): "narrow" → "not widened" — DELIVERED. objectql.ts:3021 and the pin header :86 now say "UNCHANGED … ⛔ Not 'narrowed'"; the changeset carries no #8993 claim at all. The containment itself re-verified on the head: the double-bucketing sits below the !groupBy return, and both id: opt.value (:994) and id: String(g) (:1009) are under schema.groupBy &&.

5. Corpus evidence re-graded — CORRECT as re-graded. The mdx fragment (content/docs/utilities/data-objectstack.mdx:334-339, { type, dataSource }) is still refused on the head at RECORD_SOURCE_REQUIRED (objectql.zod.ts:1253-1264; the 7780 pin asserts it and I ran it), so it is evidence of a documented lane-less authoring only — the docblock, the changeset and the PR body now all say so. The ListView evidence stands alone and is stated accurately: packages/plugin-list/src/ListView.tsx:2570 laneField = … || undefined, :2592 groupBy: laneField, :2562 "objectDef loads async: until it lands this stays undefined"; packages/types/src/record-semantics.ts:39-54 returns null on stageField: false and when none of status / stage / state / phase exists by name or type. Both faces refused that node at merge-base; the head accepts it (7780 pin, run).

6. Findings of this round — none blocking.

  • (a) Three in-source comments still assert the old contract, none on a published surface: packages/types/examples/zod-validation-example.ts:139 ("groupBy is REQUIRED on it", present tense; examples/ is type-checked but not in files), packages/app-shell/src/views/metadata-admin/previews/block-config.ts:227 and packages/app-shell/src/views/metadata-admin/i18n.ts:609 (the app-shell: the page-block designer's object-kanban inspector writes groupField — a key the renderer never reads and, after objectui#7322, the validator refuses by name; no groupBy / limit control #7772 narrative whose test twin this PR re-tensed but whose source comment it did not). Same class round 1 graded non-blocking; one line each, for the next touch of those files.
  • (b) Counting nit: the groupBy docblock and the pin file say effectiveColumns "falls past all three of its schema.groupBy && guards"; there are two && guards (:994, :1009) plus the !schema.groupBy test inside the string branch (:982). The behaviour claim (returns []) is right and pinned.
  • (c) The dev's own flag on the zod-side comment (objectql.zod.ts:1279-1281, "refused a document the protocol accepts — including … { type, dataSource }"): literally true — the fragment was and still is refused, and the protocol accepts that element shape through page.zod.ts:350 (dataSource is a page-element key, not an ObjectKanbanPropsSchema key) — but it carries the implication round 1 had removed on the TS twin. Non-blocking; one line when PR fix(types): collapse the redundant UserActionsSchema extension, correct its docblock (objectui#8992) #9017's hold on that file lifts.

② Semver

.changeset/8990-object-kanban-groupby-optional.md declares '@object-ui/types': minor. Level consistent with the change: major is forbidden outright (scripts/check-changeset-no-major.mjs; AGENTS.md §版本号策略 — objectui's own breaking moves ship as minor with the break spelled out in the body), and every sibling move on this schema is minor on the same package: 7780 (objectName required → presence rule), 8913 (columns declared), 8802 (family retirement). The other two touched packages carry test-only (app-shell) and README + test-only (plugin-kanban) changes — no runtime change, no bump owed; Changeset Declaration, Changeset Bump Policy and Changeset Fixed Group Check are all green on the head. Nit, non-blocking: the body states the author-side widening ("nothing that validated before stops validating") but not the reader-side consequence for TS consumers — schema.groupBy is now string | undefined — which is the "breaking 语义" the policy asks the body to spell out; every in-repo read is guarded (renderer sites above; Type Check green). Release-time note, not for this PR: the pending sibling .changeset/8913-object-kanban-columns-declared.md:65-69 still says the bare-string arm is "inert" and "tracked as objectui#8990"; both ship in one release, so the central release-notes author should reconcile them — that changeset is another card's record and this PR is right not to edit it.

③ Boundary flags

Dev flags (PR body "Acceptance notes", the round-2 os-dev-report's open_questions and out_of_scope_findings), one by one:

  1. open_questions[0] — the KanbanImpl empty-state gap (A: comment on finding(plugin-kanban): chunk 加载与数据 commit 是两个无序竞态 —— objectui#8534 关掉了镜子那一半,React.lazy 这一半仍然开着 #8827 / B: new card / C: seat takes it). Answered: B; A is unavailable. objectui#8827 is closed, and its subject is the React.lazy chunk / data-commit race — it introduced the role="status" live region this rig settles on, but a closed card on a different subject does not own a new finding. Dedupe measured: search/issues isBoardEmpty → 0 cards. The finding stands as round 1 stated it (KanbanImpl.tsx:882 isBoardEmpty = totalCardCount === 0 && boardColumns.length > 1, groupBy-blind, pre-existing; this widening lets an os check-clean { type, objectName } board reach a silent blank with no diagnostic). Not a blocker on this PR; filing is the seat's act after landing, and the dev's measurement paragraph is the card body. This also corrects round 1's "written onto finding(plugin-kanban): chunk 加载与数据 commit 是两个无序竞态 —— objectui#8534 关掉了镜子那一半,React.lazy 这一半仍然开着 #8827 or filed" — only the second half exists.
  2. objectql.zod.ts vs PR fix(types): collapse the redundant UserActionsSchema extension, correct its docblock (objectui#8992) #9017 — accepted, measured. git merge-tree --write-tree fa93c7fc 2f430a31 (fix(types): collapse the redundant UserActionsSchema extension, correct its docblock (objectui#8992) #9017's head) → clean tree 0d4298b1…, exit 0, no conflict list, in a clone with no merge driver registered (the honest textual probe per platform-readings.md). Ordering at landing is the seat's concern; nothing owed here.
  3. Zod-side comment left imprecise (dev's own flag) — accepted as flagged, see ①6(c). Non-blocking.
  4. Citation-gate blind spot — escalated to the seat, unchanged. Round 1 measured it (a continuation :NNN is recorded only within 2 lines of a numbered hit); the seat reserved the filing; search/issues cross-file-line-citation created:>=2026-09-10 → no card yet at this reading. Not this PR's.
  5. Process failure (ablation on an uncommitted tree) — disposed. The head's faces are the intended ones (①1), and both ablations above ran against the committed head in a separate clone.
  6. availableViews reads only view-level bindings — accepted. ListView.tsx:2183 memo, :2203 schema.kanban?.groupByField; the node schema is not read; the file is untouched by this PR.
  7. No upstream spec card — correct. The protocol is right on this repository's own evidence (①5); the installed 17.4.0 and origin/main agree; there is nothing to correct upstream.

Independence

Implemented-by: claude/issue-8990-kanban-groupby-optional (mode:subagent dev — recorded by its branch, per its claim comment on #8990)
Reviewed-by: session_01Jmxdo7bmeqCQHLSfmLVX9w (this record: context-isolated review subagent agent-a6f4bd6d929085ed0 of that session; harness model claude-fable-5-1, 95/95)

Same-session test, applied literally to the two identities as the charter defines them (a mode:subagent dev by its claude/… branch, the reviewer by its session): the tokens differ, so this record is NOT a SELF-REVIEW under contract-review.md 「两者同 session ⇒ 报 SELF-REVIEW」. Stated plainly so nobody has to infer it: the implementing subagent was dispatched by session_01Jmxdo7bmeqCQHLSfmLVX9w and this reviewer is a subagent of that same session. The charter's reading records the subagent dev by its branch precisely so that configuration is not read as self-review, and the independence it relies on is the one applied here — this subagent was fed the card, the existing rulings and the PR only, not the seat's conclusions, and every judgment above was re-derived from the tree with a control that would have fired had the claim been false. Round 1 reached SELF-REVIEW by widening the test to the parent session; under the charter text as read at cac62d5f that widening is not the test.

Owed

Nothing blocking. Non-blocking, for the next touch of each file: ①6(a) three source comments; ②'s reader-side sentence in the changeset; ①6(b)/(c) wording.

VERDICT: PASS

What I did not measure


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — carriers cleared, landing

Clause-② contract review: VERDICT: PASS — record 5624643821 (#9021 (comment)), judged on head fa93c7fc259025528afee61e5fe9ead3926ac3dc, which is still the head at this write.

Reviewer ran at CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (objectstack origin/main scripts/pm/dispatch-gates.mjs:10507), transcript-verified 95/95 stamps, one distinct value, no fallback. Charter read at objectstack origin/main cac62d5f9de14f1d332c0bdb3b3630bec62b6e39; I re-fetched to efa2533dd88a6c8304761253c9916f81699c3dc9 before clearing and confirmed the pm-dispatch skill and scripts/pm are byte-unchanged between the two — with a full-tree control showing 8 other files did change, so the empty path-scoped diff is a reading and not a broken instrument.

Independence — NOT self-review, and this is the round that settled it:

Implemented-by: `claude/issue-8990-kanban-groupby-optional` (mode:subagent dev — recorded by its branch)
Reviewed-by: `session_01Jmxdo7bmeqCQHLSfmLVX9w` (context-isolated review subagent, harness model claude-fable-5-1, 95/95)

The charter records a mode:subagent dev by its branch because such a dev has no session of its own; the same-session test therefore compares a branch token against a session token and does not collapse. Round 1 on the sibling PR declared SELF-REVIEW by widening that test to the parent session, which the charter's own text forecloses. Both parties are subagents of one dispatch session, and the review record says so plainly.

Landing pre-checks — three, all measured just now

check reading
① in-seat clause-② review PASS on record, 同形, ceiling tier ✅ record 5624643821 on the head
check-clause2-carriers.mjs --pair 9021 rc 0 — both carriers agree, declaration readable in the fixed spelling
check-widening-tells.mjs --declaration yes --files rc 0 — a yes already routes to review, so a tell decides nothing
③ every check on the head, ⛔ not a required subset 35 check runs, 32 success + 3 skipped, 0 failed, 0 pending; total_count 35 = returned array length 35, so nothing hid behind pagination; commit status Vercel success
governed surface check-governed-merges.mjs --test over the 10 real changed paths → 0 of 10 hit the register ⇒ NOT governed, ordinary queue landing
mergeability mergeable: true, mergeable_state: clean

⚠️ One gate reading I took and discarded rather than used: check-governed-merges.mjs --pr 9021 resolves the PR against GOVERNED_REPOS[0]objectstack-ai/objectstack — regardless of PM_SWEEP_REPO. It read objectstack's PR #9021 (1 changed file) and returned a confident ✅ NOT governed about a different repository's PR. The --test reading above, over this PR's own file list, is the one that counts. Filed as a separate finding.

What the review left for the seat, ⛔ not for this PR

Both carriers (needs:contract-review on this PR and on card #8990) are being stripped in this same act, and the PR flipped ready with auto-merge attached.

⛔ Base is not being hand-merged. The merge queue rebuilds each PR on current main itself; hand-merging would move the head and void the review record above — it cost this seat a full round earlier today.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants