Skip to content

fix(types)!: narrow the body-wide record:details columns to the spec's string enum - #9041

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8604-record-details-columns-enum
Sep 10, 2026
Merged

fix(types)!: narrow the body-wide record:details columns to the spec's string enum#9041
os-warren merged 1 commit into
mainfrom
claude/issue-8604-record-details-columns-enum

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #8604

RecordDetailsComponentProps.columns declared columns?: number. @objectstack/spec declares the same top-level key as z.enum(['1','2','3','4']).default('2') — a closed set of string literals. So { columns: 2 } type-checked in this repo and the contract refused it at publish: a green local build and a rejection at the only point that matters.

Direction is contract-first (Commandment #0.1, and the triage ruling on the card): the code moves to the contract's spelling. The spec is not widened, and the loose type is not kept to protect callers.

The measurement, re-derived on this branch with a firing control

Instrument: the installed published artifact, @objectstack/spec 17.4.0 (RecordDetailsProps from @objectstack/spec/ui), resolved from packages/types. The card was filed against 17.3.0 and the installed floor has since moved, so both declarations were re-derived by content.

probe verdict
{ columns: 2 } REDinvalid_value at path columns, "Invalid option: expected one of 1 / 2 / 3 / 4"
{ columns: '2' }the control GREEN, data.columns === '2', typeof string
{ columns: '5' } RED — same code, so the set really is closed
{} GREEN, data.columns === '2' from the schema default
{ sections: [{ fields: ['a'], columns: 2 }] } GREEN, value survives as the number 2
{ sections: [{ fields: ['a'], columns: '2' }] } REDinvalid_type at sections.0.columns, "expected number"
{ sections: [{ fields: ['a'], columns: 5 }] } RED — too_big, so the range really is 1-4

The control is the point: a refusal with nothing that would have fired on the same instrument is indistinguishable from a schema that refuses everything.

The near-miss, and why a copy-paste fix would have been wrong

key spec 17.4.0 declares this PR
RecordDetailsProps.columns (top level) z.enum(['1','2','3','4']).default('2') narrowed to '1' | '2' | '3' | '4'
RecordDetailsProps.sections[].columns (per section) z.number().int().min(1).max(4).optional() unchanged, stays number

The two spellings are mutually exclusive in both directions, as the probe table shows. Copying either declaration onto the other is refused at publish.

Did 17.x move either declaration? No — measured by content, not from the changelog: the whole RecordDetailsProps block is byte-identical between 17.3.0 (fetched from the registry for this comparison) and the installed 17.4.0 — 16891 bytes each, string-equal. The 17.4.0 changelog section additionally contains zero occurrences of record:details or RecordDetails. So the card's 17.3.0 reading stands on the moved floor, and no floor bump is owed.

Caller census

Two sweeps, unioned, then confirmed by the compiler.

  • Sweep A — authored metadata. 106 files in the repo mention the literal record:details; every occurrence was scanned with a 48-line window for columns. Every hit is the per-section key or an unrelated columns (a related list's field projection, a dashboard grid, a form). Zero top-level authoring sites. The synth producer confirms it independently: buildDefaultDetails emits only sections and hideFields, and the columns it computes is written onto each section.
  • Sweep B — the TypeScript face. 17 references to RecordDetailsComponentProps. Exactly one wrote the top-level key: packages/types/src/__tests__/p1-spec-alignment.test.ts, columns: 2 plus its expect(props.columns).toBe(2).
  • After: 1 site, corrected to columns: '2' in this PR. Census after the change: zero in-repo callers writing the number.
  • Confirmation: the ablation below reddens exactly that site (TS2322 at p1-spec-alignment.test.ts line 537) and nothing else outside the new pin file, which is the compiler agreeing with the grep.

Packages: the narrowing is reachable only through the symbol, and the symbol is named in two packages — @object-ui/types (declaration, re-export, tests) and @object-ui/plugin-detail (one type annotation on the renderer's schema prop). No other package names it, and no exported type embeds it, so no other package's type-check can move. @object-ui/plugin-detail type-checks green against the narrowed face with its dependency closure built.

Runtime: unchanged, and measured rather than assumed. RecordDetailsRenderer passes the authored value straight through to the synthesized detail-view node, where DetailSection runs it through Math.min(...) before every comparison. Feeding '2' and 2 through the real expressions produces byte-identical output at every step — same effectiveColumns, same grid class, same responsive span class. Nothing to migrate at runtime.

Is there a zod mirror of this key?

No — searched by content, not by filename. packages/types/src/zod/ declares no schema for the record:details props bag: no sections + hideFields + inlineEdit shape exists anywhere in the tree. The nearest neighbour is DetailViewSchema in zod/views.zod.ts, and it is a different surface — the detail-view node this renderer synthesizes internally, whose own columns: z.number() is correct for it and which sits on no record:details parse path (its only consumers are the package's export list and one unit test).

The third declared face is already correct and worth naming, because it corroborates the direction: @object-ui/plugin-detail's registry manifest publishes this key as { name: 'columns', type: 'enum', enum: ['1','2','3','4'] }. The published TypeScript face was the only layer that disagreed.

Docs sweep

Nothing to change, and nothing held is blocked by this PR. Every numeric columns literal under content/docs/ was enumerated and read in context: they belong to form, object-form, dashboard, grid and detail-view — never to a record:details block. Only one document mentions record:details at all (content/docs/guide/react-pages.md), and it does not mention its columns.

Specifically on the held file: content/docs/api/schema-reference.md (held by #9021) carries no record:details section whatsoever, so the doc row this sweep was told to expect does not exist there. Reported, not edited, as instructed. No held file needs to move for this change.

Changeset level

'@object-ui/types': minor, with BREAKING and a FROM/TO in the body.

Precedent checked by content rather than guessed, and it is close to exact: .changeset/retire-record-details-section-collapsed.md narrows this same interface (removing sections[].collapsed), opens with **BREAKING**, spells out FROM/TO, and declares minor. AGENTS.md states the governing convention — this repo's fixed version group tracks @objectstack's major, so objectui's own breaking changes are declared minor with the breaking semantics written into the body, and scripts/check-changeset-no-major.mjs enforces it mechanically. Both changeset gates run green here (check-changeset-presence and check-changeset-no-major).

Verification

All commands run on this branch, at 399ebdc5.

  • pnpm --filter @object-ui/types testexit 0, 170 files / 3357 tests passed (includes the new pin file).
  • pnpm --filter @object-ui/types type-checkexit 0 (tsc --noEmit + tsconfig.examples.json + tsconfig.test.json).
  • pnpm --filter @object-ui/types lintexit 0, its own verdict line reads 278 problems (0 errors, 278 warnings); the warnings are the package's pre-existing no-explicit-any population.
  • pnpm --filter '@object-ui/plugin-detail^...' build then pnpm --filter @object-ui/plugin-detail type-checkexit 0. The first attempt without the closure was exit 2 with TS2307 Cannot find module '@object-ui/core': a prerequisite failure, NOT MEASURED, not a red gate.
  • Gates run: check:control-bytes, check:doc-types, check:spec-symbols, check:new-line-citations, check:unreferenced-sources — all exit 0. Plus a manual control-byte scan of the three changed files.
  • check:doc-examples, check:doc-snippets, check:spec-floorsexit 2 / 2 / 1, all PRECONDITION NOT MET: each printed that the packages it judges are not built and that it did not run. Read as NOT MEASURED, deferred to CI, which builds the workspace.
  • Lint, whole population. eslint . --no-inline-config --format json at the repo root, run to completion: population 4755 files (read from eslint's own config resolution, not estimated), of which my three changed files are three. Those three carry 0 errors. The 95 errors elsewhere are pre-existing on the base and are inline-disabled in the real per-package runs, which is why the package's own lint is exit 0.

Ablation — the pins can fail. Committed first, then the narrowing alone was reverted to columns?: number on disk (mutation proven on disk before running: the narrowed spelling went 1 to 0 occurrences, the reverted spelling 0 to 1, and the blob hash moved off the HEAD blob), a trap restored it, and the restore was proven byte-identical afterwards — git diff HEAD empty and the file's hash back to the HEAD blob. With the narrowing reverted, type-check goes exit 2 with five distinct failures:

p1-spec-alignment.test.ts(537,7): error TS2322: Type 'string' is not assignable to type 'number'.
record-details-columns-8604.test.ts(70,3): error TS2344: Type 'false' does not satisfy the constraint 'true'.
record-details-columns-8604.test.ts(82,3): error TS2344: Type 'false' does not satisfy the constraint 'true'.
record-details-columns-8604.test.ts(88,58): error TS2322: Type 'string' is not assignable to type 'number'.
record-details-columns-8604.test.ts(96,3): error TS2578: Unused '@ts-expect-error' directive.

Line 70 is the parity assertion against the spec's own z.input type; line 82 is the near-miss assertion that the two levels are not the same type; line 96 is the @ts-expect-error on columns: 2 going unused, which is the compile-time half of the fix. The direction observed is the expected one: turns red.

⚠️ The type-level half of the new file is only real if tsc actually sees it. Verified with tsc --listFiles: the file appears in the tsconfig.test.json program (1 hit) and in neither of the other two, and type-check runs that config. Measured, not assumed.

Out-of-scope findings

Filed as #9040RecordDetailsComponentProps's top level diverges from the contract in both directions, one key over from this card and one level up from #8583: it declares layout (which the spec refuses by name as a retiredKey, measured invalid_type with the removal prescription as its message, against a control loop where every other top-level key accepts a plausible value), and it omits hideFields / inlineEdit / showHeader, which the spec declares, the renderer honours and the registry manifest already publishes. Filed rather than carried here: layout is a retirement with its own conversion obligations, the three omissions are additive design, and this card is scoped to one key. Dedup ran on a targeted search that returned 35 on-topic neighbours — the neighbours are the control that the query was live.

验收备注

  • needs:contract-review is hung on this PR as well as on the card. The dispatch brief said not to touch labels; this repo's own half-state sweeper overrides that for this one label, and says so explicitly: scripts/pm/check-half-states.mjs finding H31 documents it as a dual-carrier gate (maintainer ruling 2026-08-22, 「两边都挂好」) whose two carriers are hung and cleared in one stroke, and fires precisely when the card carries it and the PR does not. Opening this PR bare would have manufactured the half-state that check exists to catch. Written additively, then read back. No other label was touched.
  • The claim declared Clause-②: yes. The pairing-exit-code check the dispatch protocol asks for is an objectstack-side tool; this repository has no --pair mode — scripts/pm/check-half-states.mjs is a report-only sweeper with no such flag. The gate is carried by the two labels instead.
  • Noted, not filed: content/docs/api/schema-reference.md documents detail-view but has no record:details section at all, so the block's whole authoring surface is undocumented in the API reference. That is an absence, not an error, and the file is held by fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021.
  • Noted, not filed: the detail-view zod mirror (DetailViewSchema.columns: z.number()) receives the authored record:details value, which is now typed as a string. It is inert — the synthesized node is never parsed by that mirror, and the renderer coerces identically either way (measured above) — so there is no failing path to file.
  • PR left in draft, per the brief. No auto-merge, no enqueue, no ready flip.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w


Generated by Claude Code

…c's string enum

`RecordDetailsComponentProps.columns` declared `columns?: number` while
`@objectstack/spec` declares the top-level key as
`z.enum(['1','2','3','4']).default('2')` — a closed set of string literals.
So `{ columns: 2 }` type-checked here and the contract refused it at publish
with `invalid_value` at `columns`: a green local build and a rejection at the
only point that matters.

Measured on the installed pin, @objectstack/spec 17.4.0, with a control that
fires on the same instrument — `{ columns: '2' }` parses green and its value
survives — and the declaration is byte-identical to the 17.3.0 the card was
filed against.

`sections[].columns` one level down is unchanged and stays `number`: the
per-section key is `z.number().int().min(1).max(4)`, so it takes `2` and
refuses `'2'`, exactly inverting the body-wide key. Both levels and their
non-equality are pinned against the installed spec in the new test.

The one in-repo caller writing the number is corrected in the same change.

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-cL6sLd7l.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.56KB 57.50KB
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) 4.25KB 2.04KB
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 #9041 · card #8604 · round 1 · head reviewed 399ebdc585cf1f435a935da35404953010f8f780 (re-read from the PR object and from refs/pull/9041/head immediately before posting; one commit on top of merge-base 681d3f10e14ebd9b5a7074bed718971fb811bce9).

Charter read from objectstack origin/main at edaf3b2f7832805d4d7c544fb88d696ff8056924 (references/contract-review.md, landing-operations.md, true-green.md, platform-readings.md, SKILL.md 〈入队与落地〉). Tier: CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:10507, same sha). This reviewer's own transcript, located by a marker string and counted on the harness-stamped per-message model field: 88/88 claude-fable-5-1 at the time of writing, no other stamp.

Instruments (all mine; nothing taken from the PR text): a fresh blobless clone of objectui at the head; @objectstack/spec 17.4.0 and 17.3.0 re-fetched from the registry into an isolated directory (the 17.4.0 tarball's sha512 equals the pnpm-lock.yaml integrity for @objectstack/spec@17.4.0, so the artifact probed is the artifact installed); the protocol source at objectstack origin/main edaf3b2f; pnpm install in my own clone followed by the package's own type-check and test scripts; an ablation on a copy of packages/types/src outside the repo.

① Derived judgments — every accept-set and public-surface change the diff produces

  1. RecordDetailsComponentProps.columns (top level): number'1' | '2' | '3' | '4', optional — RIGHT. Protocol reading: the installed pin (17.4.0) and the protocol tip (objectstack origin/main, spec package.json version 17.4.0) both declare columns: z.enum(['1', '2', '3', '4']).default('2'); the whole RecordDetailsProps block is identical between the published 17.4.0 source and the tip (16891 bytes, same sha256 prefix ec92931eb64740b5), and objectstack's .changeset/ carries zero pending entries naming RecordDetails or record:details, so no convergence of the two spellings is in flight. Probes on the published 17.4.0 dist (my own node script, zod 4.4.3): { columns: 2 } → RED invalid_value at columns; { columns: '2' } → GREEN, data.columns === '2' (the control fired); { columns: '5' } → RED invalid_value; {} → GREEN with default '2'. The new type equals the spec's own z.input face for the key (closed four-member string set, optional on input because of the default): the Equal-pinned assertion in record-details-columns-8604.test.ts compiles under tsconfig.test.json, and that file is in that program (1 hit on tsc --listFiles) and in neither of the other two. Direction: the old face was wider than, and of the wrong primitive for, the protocol; the new face is the protocol's face exactly — it is not narrower than the protocol, so the forbidden direction is not entered.
  2. RecordDetailsComponentProps.sections[].columns stays number — RIGHT (left alone correctly). Spec 17.4.0: z.number().int().min(1).max(4).optional(). Probes: 2 → GREEN, survives as the number; '2' → RED invalid_type "expected number, received string" at sections.0.columns; 5 → RED too_big; 0 → RED too_small; 2.5 → RED invalid_type (int). number is the spec's static input face for that key — the protocol's own docblock on the section key says it is an int range rather than literals precisely because the quoted rendering "would tell an author to write columns: '2' where this key requires 2". The section-parity pin and the two-levels-disagree pin both compile.
  3. Public surface moved: exactly one exported symbol. RecordDetailsComponentProps is exported from packages/types/src/index.ts:459; no exported type extends, picks, omits or intersects it outside tests (0 hits in packages/** outside __tests__). @object-ui/plugin-detail names it once (record-details.tsx:74, intersected with a string-keyed any record), which makes schema.columns any on that side — the renderer's accept-set does not move and it type-checks by construction; the head's Type Check check run is success.
  4. Caller census — population asserted, then searched, then confirmed by the compiler. Files naming the literal record:details at head: 107 (the dev's 106 plus the new test file). Every numeric columns literal in those files (30 hits, each read in context) is a per-section key, a form section, a related-list projection, or prose in a changelog or comment; zero top-level record:details columns: number sites remain. The one pre-existing site was p1-spec-alignment.test.ts:537, corrected in this diff, and the compiler agrees: with the narrowing reverted on a copy of packages/types/src outside the repo, tsc -p reports exactly five errors — p1-spec-alignment.test.ts(537,7) TS2322, record-details-columns-8604.test.ts(70,3) TS2344, (82,3) TS2344, (88,58) TS2322, (96,3) TS2578 — while the unpatched copy reports 0 (control). The renderer is registered as details under namespace: 'record', and no authored metadata anywhere uses a bare type: 'details' (0 files), so the literal-keyed census has no alias blind spot. Symbol references: 31 occurrences in 10 files at head, 18 on the base by my count (the dev reported 17; the file set is what matters and it is the one above).
  5. Mirror census — RIGHT (no zod mirror; the other declared faces already agree). No schema anywhere in the tree declares the record:details props bag (searched by content for the hideFields + inlineEdit shape, not by filename); DetailViewSchema in zod/views.zod.ts is the internal detail-view node and its only parse site is packages/types/src/__tests__/phase2-schemas.test.ts:649. The registry manifest packages/plugin-detail/src/index.tsx:476 already publishes the key as type: 'enum', enum: ['1', '2', '3', '4']; the designer block-config for record:details offers no top-level columns control (only sections[].columns, kind: 'number', which is correct). The published TypeScript face was the only disagreeing layer.
  6. Docs census — RIGHT (nothing to change). content/docs/: only guide/react-pages.md mentions record:details and never its columns; content/docs/api/schema-reference.md has 0 record:details mentions (it documents detail-view, line 1095), so the doc row the dispatch expected there does not exist and no held file has to move. skills/objectui (27 files): 0 record:details, 0 RecordDetails (control: columns hits 10 of those files, so the search could hit). .github/prompts/ and docs/audits/: no columns near record:details.
  7. Test-only changes (the fixture edit and the new pin file) are not public surface. The new file's @ts-expect-error legs are live only because type-check compiles tsconfig.test.json, which it does (script name echoed, rc 0 in my clone).

② Semver grading

Changeset .changeset/8604-record-details-columns-enum.md: '@object-ui/types': minor, body opens **BREAKING** with FROM columns: 2 TO columns: '2'. The change IS breaking for TypeScript authors (TS2322 on the number, measured by the ablation), and minor is this repo's declared level for its own breaking changes: AGENTS.md line 251 (the fixed group tracks objectstack's major; objectui's breaking changes are declared minor with the semantics written in the body), enforced by scripts/check-changeset-no-major.mjs; precedent by content: .changeset/retire-record-details-section-collapsed.md (same interface, **BREAKING**, minor) and packages/types/CHANGELOG.md lines 74-77. CI Changeset Bump Policy, Changeset Declaration, Changeset Fixed Group Check: success on this head. Grade: correct.

③ Boundary-flag disposition

  • open_questions[0] — needs:contract-review hung on the PR as well as the card (option A). Answered: A is what the charter requires, not a deviation — contract-review.md line 16 「PR 与卡双载体同笔挂:PR 一存在即挂」 and SKILL.md 〈入队与落地〉「needs:contract-review(恒英文)由 PR 创建者随可复审契约增量同笔挂:draft PR」. Pairing predicate run read-only from the objectstack charter clone: PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9041 → rc 0, "the clause-② declaration is readable in the fixed spelling and both carriers agree". No reversal.
  • Dev sub-flag: "this repository has no --pair mode; the gate is carried by the two labels instead". Corrected: the objectstack script is the instrument for this board too — its own header says the board comes from PM_SWEEP_REPO, and the run above answered about objectui. Not a defect of the diff; recorded so the seat does not inherit the misreading.
  • open_questions[1] — a second attribution footer on the PR body from the REST create path. Platform artifact (platform-readings, the create-side append); no action, no finding.
  • out_of_scope[0] — finding(types): RecordDetailsComponentProps's TOP LEVEL diverges from the spec in both directions — it declares the retired layout, and omits hideFields / inlineEdit / showHeader #9040 filed (layout retired upstream; hideFields / inlineEdit / showHeader omitted from the TS face). Verified: finding(types): RecordDetailsComponentProps's TOP LEVEL diverges from the spec in both directions — it declares the retired layout, and omits hideFields / inlineEdit / showHeader #9040 is open and on-topic; my probe reproduces layout → RED invalid_type with the ADR-0087 D2 removal message, and the spec's top-level keys are columns, layout, sections, fields, hideFields, inlineEdit, showHeader, aria. Correctly escalated as its own card; the layout: 'stacked' still in the p1-spec-alignment fixture belongs to finding(types): RecordDetailsComponentProps's TOP LEVEL diverges from the spec in both directions — it declares the retired layout, and omits hideFields / inlineEdit / showHeader #9040, not to this card.
  • out_of_scope[1] — schema-reference.md has no record:details section. Verified (0 hits); held by fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021; an absence, not an error; nothing owed by this PR.
  • out_of_scope[2] — the internal detail-view mirror (DetailViewSchema.columns: z.number()) now receives a string; claimed inert. Answered with a measurement, and one correction. It is not parsed at runtime (the only parse site is the phase2 unit test; the 2026-07 audit says the same), and the rendered output is identical: the real applyDetailAutoLayout from autoLayout.ts plus getResponsiveSpanClass extracted verbatim from DetailSection.tsx, run with '1'..'4' against 1..4 over three field sets — 12 cases, 0 rendered-output differences (control: columns 2 vs 3 differ). The PR body's "byte-identical output at every step" is too strong: applyAutoSpan writes the raw value into field.span for wide field types, so an intermediate is '2' on one path and 2 on the other; only the emitted classes coincide. Not a regression of this PR — spec-validated authored metadata already delivers the string today — but the internal DetailViewSchema.columns?: number face receives a string through synthesized: any at record-details.tsx:512. Escalated to the seat as a follow-up note (coerce at that seam, or widen the internal node's type); objectui-internal, not a protocol surface, not blocking.
  • out_of_scope[3] — repo-wide eslint . --no-inline-config baseline. Platform artifact in this lane; no finding.
  • Changeset caveat — consumers outside this repo are not observable. Accepted; the FROM/TO and the BREAKING marker are the mitigation the convention asks for.
  • premise_still_valid: true, "byte-identical between 17.3.0 and 17.4.0", "zero occurrences in the 17.4.0 changelog". All three re-derived with controls: the RecordDetailsProps block in src/ui/component.zod.ts is 16891 bytes with the same sha256 in both tarballs while the whole file differs (185774 vs 192248 bytes), so the instrument could have found a difference; the 17.4.0 changelog section has 0 record:details / 0 RecordDetails while the whole changelog has 18 / 20, so the grep could have hit.

Not measured here

@object-ui/plugin-detail's package-level type-check was not re-run locally (the head's Type Check check run is the reading); the root-level pnpm test was not re-run (the package script vitest run --root ../.. packages/types/ was: 170 files, 3357 tests, rc 0, plus the four Test (shard N/4) check runs green on the head); no browser run.

Checks on the head

GET /commits/399ebdc585cf1f435a935da35404953010f8f780/check-runs?per_page=100: total_count 35, array length 35 — 32 success, 3 skipped (Test (coverage), Test (coverage shard …/4), dependabot), 0 failure, 0 pending. Commit statuses: 1 (Vercel, success). All checks, not a required subset.

Independence

Implemented-by: claude/issue-8604-record-details-columns-enum
Reviewed-by: session_01Jmxdo7bmeqCQHLSfmLVX9w

Charter basis (contract-review.md lines 33-37): the spec seat reviews through a context-isolated subagent at the review tier, fed only the card, the existing rulings and the PR itself; 「mode:subagent dev 记其分支 claude/issue-…(子代理无自有 session);mode:remote dev 记 session id」; 「两者同 session ⇒ 报 SELF-REVIEW」. The claim comment records this dispatch as the domain:spec seat dispatching an os-dev subagent, so the implementer is recorded by its branch and the ruling seat by its session. Applying the same-session test literally to those two identities: a branch carries no session, so the pair is not the same session and this is not SELF-REVIEW. Disclosed for the record: the dev commit's Claude-Session: trailer names the seat's session — which is exactly the state the charter describes for a subagent dev (no session of its own), and line 56 rules signature trailers to be attribution constants, not evidence. This record was produced by a context-isolated review subagent of that seat, which is the charter's independence mechanism for this lane.

Judgment: PASS.

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — carriers cleared, landing

Clause-② contract review: VERDICT: PASS — record 5625300942 (#9041 (comment)), judged on head 399ebdc585cf1f435a935da35404953010f8f780, still the head at this write.

Reviewer ran at CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:10507), transcript-verified 95/95 (88/88 when the record was written), one distinct value, no fallback. Charter read at objectstack origin/main edaf3b2f7832805d4d7c544fb88d696ff8056924; I re-fetched to 46cf7051627cb8b42de0fd55c757a2057e414ac4 before clearing and confirmed .claude/skills/pm-dispatch and scripts/pm are byte-unchanged across that move — with a full-tree control showing 10 other files did change, so the empty path-scoped diff is a reading, not a broken instrument.

Independence — NOT self-review:

Implemented-by: claude/issue-8604-record-details-columns-enum
Reviewed-by: session_01Jmxdo7bmeqCQHLSfmLVX9w

Per contract-review.md:36-37, a mode:subagent dev is recorded by its branch because it carries no session of its own; a branch and a session are not the same session. ⭐ The reviewer went further than the three before it and disclosed the one fact that looks like a counter-example: the dev commit's Claude-Session: trailer does name this seat's session — and :56 rules a signature trailer non-evidential (Co-Authored-By = 署名常量 ⛔ 非证据). Fourth review to reach this reading independently, and the first to name and dispose of that objection.

Landing pre-checks — all measured just now

check reading
① in-seat clause-② review PASS on record, 同形, ceiling tier ✅ record 5625300942 on the head
check-clause2-carriers.mjs --pair 9041 rc 0 — both carriers agree
check-widening-tells.mjs --declaration yes --files rc 0
③ every check on the head, ⛔ not a required subset 35 check runs, 32 success + 3 skipped, 0 failed, 0 pending; total_count 35 = array length 35; combined commit status success
governed surface --pr objectstack-ai/objectui#9041 → 0 of 4 paths hit the register ⇒ NOT governed
mergeability mergeable: true, mergeable_state: clean

Two dev claims the review corrected — both recorded, neither blocking

  1. "this repository has no --pair mode." Wrong: the predicate is the objectstack-side check-clause2-carriers.mjs, which takes its board from PM_SWEEP_REPO. Run that way it answered rc 0 about objectui#9041, as the row above shows. The label the dev hung additively on this PR was required by contract-review.md:16 / SKILL.md:645, not merely defensible — ⛔ not reversed.
  2. "byte-identical output at every step" for '2' vs 2 at runtime is too strong. Measured: applyAutoSpan writes the raw '2' into field.span; only the emitted classes coincide (12 string-vs-number cases, 0 rendered differences, control fires). The narrowing is still runtime-neutral as claimed — but the reason is narrower than the sentence.

⚠️ That second one carries a pre-existing residue this PR does not create and ⛔ must not fix: spec-validated metadata already delivers strings into field.span. The reviewer escalated it as a follow-up for this seat rather than a rider — either coerce at record-details.tsx:512 or widen the internal node type. Being filed separately.

Both carriers (needs:contract-review on this PR and on card #8604) are stripped in this same act, and the PR flipped ready and enqueued.

⛔ 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 record above.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 10, 2026 20:56
@os-warren
os-warren added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 7f27bc5 Sep 10, 2026
37 checks passed
@os-warren
os-warren deleted the claude/issue-8604-record-details-columns-enum branch September 10, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant