You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
finding(types): the TOP-LEVEL RecordDetailsComponentProps.columns is number, but the spec declares z.enum(['1','2','3','4']) — { columns: 2 } type-checks here and is refused at publish #8604
Filed by the domain:spec @ objectui seat (session_01Jmxdo7bmeqCQHLSfmLVX9w) from a contract-review verdict on PR #8601 (card #8583). Measured by an independent reviewer running at CONTRACT_REVIEW_TIER against the installed @objectstack/spec 17.3.0; full verdict at objectui#8583 comment 5587871554. ⛔ Not claimed, ⛔ not graded — domain:* and priority are triage's.
What
RecordDetailsComponentProps (packages/types/src/record-components.ts) declares its top-levelcolumns as:
columns?: number;
The installed @objectstack/spec 17.3.0 declares the same top-level key as z.enum(['1','2','3','4']) — a closed set of string literals.
Measured on the installed spec: { columns: 2 } → invalid_value at columns.
⇒ A TypeScript author writes columns: 2, tsc accepts it, and the contract refuses it at publish. The declared face is wider than — and the wrong primitive type from — what ships.
⚠️ Note the asymmetry with sections[].columns, because they look identical and are not
objectui#8583 item 1 (PR #8601) declared sections[].columns as number, and that is correct: the spec declares the per-section key as z.number().int().min(1).max(4), so number is exactly the spec's static input face. The reviewer verified that key-for-key.
⇒ The same word names two different types one level apart — number on a section, a string enum at the top level. That is precisely the shape that makes a copy-paste fix wrong, and the reason this is filed separately rather than folded into #8583.
Why it is not cosmetic
This is the identical defect class objectui#8583 exists to close — a published TypeScript face disagreeing with the contract — one level up, and it runs in the hiding direction: the type says yes, the contract says no, and the author finds out at publish time (or, if publish validation is advisory on that path, not at all).
Candidate resolutions — ⛔ deliberately not decided here
Narrow the local declaration to the spec's spelling ('1' | '2' | '3' | '4'). Contract-first, and the direction the lane normally takes. ⚠️ It is a narrowing of a published type, so today's columns: 2 authors break — which makes it a contract decision rather than a rider.
Ask upstream why the top level is a string enum when the per-section key is an int range, and converge there instead. ⚠️ Possibly the real defect: two spellings for one concept in the contract itself.
⛔ Option 2 lands in another repo, and option 1 narrows a shipped type — neither is a seat's to take unilaterally.
Dedup — with its control
repo:objectstack-ai/objectui RecordDetailsComponentProps columns → 4 hits, none of them this: objectui#6790 (record-alert's readProps spread), objectui#7835 (stale renderer comments, closed), objectui#3807 (the sectionsinput description teaching a retired shape, closed), objectui#736 (the ListView protocol gap checklist, closed). The control is that the query returned real, on-topic neighbours — including two prior record:details contract-drift cards — so the absence of this one is a reading, not a dead query.
Refs: objectui#8583 / PR #8601 and verdict 5587871554 (where this was measured) · objectui#3807 (same component, adjacent drift, closed)
Triage: finding admitted as class (c) — a metadata trap. Lands in packages/types/src/record-components.ts; domain:spec; priority:p2.
Class (c) is the one about "AI 写元数据会被运行时拒收或静默丢弃的陷阱", and this is a clean instance:
surface
says
RecordDetailsComponentProps.columns (this repo)
columns?: number
installed @objectstack/spec 17.3.0, same top-level key
z.enum(['1','2','3','4']) — closed set of string literals
⇒ { columns: 2 }type-checks here and is refused at publish (invalid_value). An author gets a green local build and a rejection at the only point that matters. That is exactly the shape the third decision axis forbids — a declaration the runtime does not honour.
Direction is settled by governing text, ⛔ not open
SKILL.md: 协议为基准:spec 与代码不一致默认改代码对齐;改协议单独立卡,⛔ 不作缺陷卡的选项. ⇒ narrow this repo's type to match spec. ⛔ Widening spec to accept number is not an option on this card.
⚠️ Narrowing a published type is a Clause-② surface change in the narrowing direction — it pulls back to an already-declared contract rather than widening, so it is in the代裁 lane's mechanical boundary. Still declare it honestly in the claim comment.
⚠️ This will break any in-repo caller passing a number. That is the defect surfacing, ⛔ not collateral damage — fix the callers in the same PR rather than keeping the loose type to protect them.
Size/model suggestion: M — the type change is one line, the caller sweep is the work.
Narrowing a published TypeScript face is a public-surface change. Triage already graded it that way and asked for it to be declared honestly. ⛔ Provisional; your diff decides.
Direction is settled — ⛔ not yours to reopen
The card lists two candidate resolutions. Triage closed that on governing text: 协议为基准,spec 与代码不一致默认改代码对齐;改协议单独立卡,⛔ 不作缺陷卡的选项. ⇒ narrow this repo's type to the spec's spelling. ⛔ Widening the spec to accept number is not an option on this card, and ⛔ neither is keeping the loose type to protect callers.
If your own measurement says the direction is wrong, ⛔ do not act on it — report it and stop. That is a ruling, not a preference.
⚠️ The trap this card is about is a NEAR-MISS, so measure both levels
The same word names two different types one level apart:
key
spec declares
RecordDetailsComponentProps.columns (top level)
z.enum(['1','2','3','4']) — closed set of string literals
sections[].columns (per section)
z.number().int().min(1).max(4) — and number here is correct, verified key-for-key on PR #8601
⇒ A copy-paste fix is wrong. Re-derive both against the installed spec by content before you touch either, and state which spec version you measured. The card's reading was taken on 17.3.0; the installed floor has moved since. If 17.x changed either declaration, that is the finding, not a footnote.
⛔ What you owe before changing a type
Re-derive the refusal yourself.{ columns: 2 } → invalid_value at columns against the real published artifact, with a firing control (a value the schema accepts, e.g. columns: '2', parsing green on the same instrument). A claim with no control that would have fired is not a measurement.
Sweep the callers, and fix them in the same PR. Triage: breaking an in-repo caller passing a number is the defect surfacing, ⛔ not collateral damage. Report the census — how many call sites, which packages — before and after.
Is there a zod mirror of this key in @object-ui/types? If so it has the same question, and the two faces must agree. Find it by content, ⛔ not by the name you expect.
Changeset level. Narrowing a published type breaks producers. Check the repo's own precedent by content (packages/types/CHANGELOG.md) and AGENTS.md rather than guessing, and say what the precedent was.
⚠️Zone-2: every claim on the card is another session's measurement, including this seat's. Re-derive on your own base. Today one dev refuted its own earlier claim, another refuted this seat's framing of a protocol alias, and a third found six rotted line anchors in a docblock this seat had cited. Finding one is worth more than compliance.
File surface — three of my PRs are open and hold these, ⛔ stay out
Measured on the open PRs just now, not remembered:
Your own surface starts at packages/types/src/record-components.ts. If the caller or doc sweep says one of the above must move, report it, ⛔ do not edit it.
Standing rules
⛔ Never edit a shared primary checkout (/home/user/objectui is shallow with a stale origin/main; /home/user/objectstack is read-only). Clone fresh; report both HEADs and clean state at start and finish.
⛔ Never git stash; ⛔ never kill by name; ⛔ never mutate node_modules; ⛔ never edit content/docs/releases/; ⛔ never write the assignee or post a second Claim:.
⛔ Commit before you ablate. A restore-from-HEAD leg silently reverted a dev's own in-progress edits today; git status caught it, but only afterwards.
Capture every rc to a file before any pipe. Read lint logs for the literal word error, ⚠️ excluding source context printed inside warnings.
⭐ Wait for CI to reach TERMINAL state before you report — a stalled or cancelled job is NOT MEASURED, neither green nor red; re-run it once rather than reporting around it.
⭐ Read the stored body back after every GitHub write. The MCP issue-create path silently strips the signature footer — measured today, and it had already cost this seat three cards. Use REST.
⛔ PR stays draft — landing is the seat's act. GitHub content in English.
{
"issue": 8604,
"status": "done",
"branch": "claude/issue-8604-record-details-columns-enum",
"pr": "https://github.com/objectstack-ai/objectui/pull/9041",
"premise_still_valid": true,
"summary": "Narrowed the body-wide `RecordDetailsComponentProps.columns` from `number` to the contract's own spelling, `'1' | '2' | '3' | '4'`, and left `sections[].columns` as `number`. Both levels were re-derived by content against the INSTALLED spec, which is 17.4.0, not the 17.3.0 the card was filed on: the floor moved, but the declaration did not — the whole `RecordDetailsProps` block is byte-identical between 17.3.0 (fetched from the registry for the comparison) and 17.4.0, 16891 bytes each, and the 17.4.0 changelog section contains zero occurrences of `record:details`. So the card's reading stands on the moved floor and no spec floor bump is owed. One in-repo caller wrote the number and is corrected here; a new pin test asserts both levels and their NON-equality against the spec's own `z.input` type, so a future 'consistency' edit unifying the two spellings turns red. A third declared face already agreed with the spec and corroborates the direction: `@object-ui/plugin-detail`'s registry manifest publishes this key as `type: 'enum', enum: ['1','2','3','4']` — the published TypeScript face was the only layer that disagreed. Runtime is unchanged and measured, not assumed: the renderer coerces through `Math.min(...)` and produces byte-identical output for `'2'` and `2` at every step. Assignee was already set by the PM seat and was not written by me; the newest `Claim:` names this branch; no second claim posted. PR left draft. No held file needed to move.",
"caller_census": {
"method": "Two sweeps unioned, then confirmed by the compiler. Sweep A: 106 files mention the literal `record:details`; every occurrence scanned with a 48-line window for `columns`. Sweep B: 17 references to the type symbol `RecordDetailsComponentProps`.",
"before_top_level": 1,
"before_sites": [
"packages/types/src/__tests__/p1-spec-alignment.test.ts — `columns: 2` plus `expect(props.columns).toBe(2)`"
],
"after_top_level": 0,
"packages_touched": [
"@object-ui/types"
],
"packages_that_name_the_symbol": [
"@object-ui/types",
"@object-ui/plugin-detail"
],
"authored_metadata_sites": "zero — every in-repo `columns` near a `record:details` node is the per-section key or an unrelated `columns` (related-list field projection, dashboard grid, form). The synth producer confirms it independently: `buildDefaultDetails` emits only `sections` and `hideFields`, and the `columns` it computes is written onto each SECTION.",
"compiler_confirmation": "the ablation reddens exactly the one grep-found site (TS2322 at p1-spec-alignment.test.ts line 537) and nothing else outside the new pin file."
},
"measurements": [
{
"what": "the card's repro, re-derived",
"instrument": "@objectstack/spec 17.4.0, RecordDetailsProps from @objectstack/spec/ui (published dist), resolved from packages/types",
"subject": "{ columns: 2 } => RED, invalid_value at path `columns`, 'Invalid option: expected one of 1|2|3|4'",
"control": "{ columns: '2' } => GREEN on the same instrument, data.columns === '2', typeof string — the control fires",
"extra": "{ columns: '5' } => RED (set is closed); {} => GREEN with the schema default '2'"
},
{
"what": "the near-miss, both directions",
"instrument": "same",
"subject": "sections[{fields,columns:'2'}] => RED, invalid_type at sections.0.columns, 'expected number'",
"control": "sections[{fields,columns:2}] => GREEN, value survives as the number 2; columns:5 => RED too_big — the range really is 1-4",
"extra": "the two spellings are mutually exclusive, so `number` is CORRECT on the section and wrong at the top level"
},
{
"what": "did 17.x move either declaration",
"instrument": "content diff, 17.3.0 tarball vs installed 17.4.0",
"subject": "RecordDetailsProps block byte-identical, 16891 bytes each, string-equal",
"control": "the two component.zod.ts files DO differ overall (diff -q exit 1), so the comparison could have shown a change",
"extra": "17.4.0 changelog section: zero occurrences of `record:details` / `RecordDetails`"
},
{
"what": "runtime behaviour under the string spelling",
"instrument": "the real expressions read off plugin-detail autoLayout.ts and DetailSection.tsx",
"subject": "'2' and 2 produce identical effectiveColumns, identical grid class, identical responsive span class",
"control": "the numeric leg is the control — it is the spelling that works today, and it lands on the same output",
"extra": "so the narrowing is a type-face change with zero runtime movement"
},
{
"what": "ablation — the new pins can fail",
"instrument": "pnpm --filter @object-ui/types type-check, after committing then reverting ONLY the narrowing on disk",
"subject": "exit 2 with five distinct failures: TS2322 at the fixed caller; TS2344 on the spec-parity assertion; TS2344 on the two-levels-disagree assertion; TS2322 on the accepted literal; TS2578 on the @ts-expect-error going unused",
"control": "on-disk mutation proven before the run — narrowed spelling 1 to 0 occurrences, reverted spelling 0 to 1, blob hash moved off the HEAD blob; restore proven byte-identical after (git diff HEAD empty, hash back to HEAD blob)",
"extra": "direction observed: turns red, the expected one. Also verified with tsc --listFiles that the new file IS in the tsconfig.test.json program (1 hit) that type-check runs — otherwise the @ts-expect-error legs would be decoration."
},
{
"what": "zod mirror of this key",
"instrument": "content search of packages/types/src/zod/ for the record:details props shape",
"subject": "NONE — no schema anywhere declares the sections + hideFields + inlineEdit shape",
"control": "the search does find every other `columns`-bearing schema in that directory (form, reports, views, complex, data-display, objectql), so it was capable of hitting one",
"extra": "nearest neighbour DetailViewSchema in zod/views.zod.ts is the internal `detail-view` node, a different surface, on no record:details parse path (consumers: the export list and one unit test). No parity row needed, so nothing was added to the held zod-mirror-parity file."
},
{
"what": "docs sweep",
"instrument": "every numeric `columns` literal under content/docs/, read in context",
"subject": "zero doc sites teach the number spelling for record:details.columns",
"control": "the sweep does return 13 numeric `columns` literals — they belong to form, object-form, dashboard, grid and detail-view — so it was capable of finding one",
"extra": "content/docs/api/schema-reference.md (held by #9021) carries NO record:details section at all, so the expected doc row does not exist there. Only content/docs/guide/react-pages.md mentions record:details, and never its columns. Reported, not edited."
}
],
"changeset": "'@object-ui/types': minor, with **BREAKING** and a FROM/TO in the body. Precedent read by content, 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 — the 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. Both changeset gates exit 0.",
"tests": "All at 399ebdc5. pnpm --filter @object-ui/types test => exit 0, 170 files / 3357 tests passed. pnpm --filter @object-ui/types type-check => exit 0 (tsc --noEmit + tsconfig.examples.json + tsconfig.test.json). pnpm --filter @object-ui/types lint => exit 0, its own verdict line reads '278 problems (0 errors, 278 warnings)'. pnpm --filter '@object-ui/plugin-detail^...' build => exit 0, then pnpm --filter @object-ui/plugin-detail type-check => exit 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 exit 0: check:control-bytes, check:doc-types, check:spec-symbols, check:new-line-citations, check:unreferenced-sources, check-changeset-presence, check-changeset-no-major, plus a manual control-byte scan of the three changed files. Gates NOT MEASURED: check:doc-examples (exit 2), check:doc-snippets (exit 2), check:spec-floors (exit 1) — each printed PRECONDITION NOT MET, that the packages it judges are not built and that it did not run; 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, my three changed files carry 0 errors; the 95 errors elsewhere are pre-existing on the base and inline-disabled in the real per-package runs. ABLATION: committed first, then reverted ONLY the narrowing on disk; mutation proven on disk (narrowed spelling 1 to 0, reverted spelling 0 to 1, blob hash off the HEAD blob), trap restore proven byte-identical after (git diff HEAD empty, hash back to the HEAD blob); type-check went exit 2 with five distinct failures. Direction: turns red. CI: TERMINAL and fully green on 399ebdc58 — 32 success, 3 skipped, 0 failed, 0 pending, polled in the foreground to terminal state, nothing stalled or cancelled.",
"mcp_calls": "1 — one targeted mcp__github__search_issues for the out-of-scope dedup, after repo-scoped REST /search/issues returned 403 (the container proxy binds sessions to repository-scoped endpoints). Channel switch declared. Every other GitHub read and write went through repo-scoped REST curl.",
"open_questions": [
{
"question": "The dispatch brief said not to touch labels beyond the repo's own labeler; my standing dev rules say a `Clause-②: yes` claim hangs `needs:contract-review` on the PR in the same stroke as the PR opens. I did NOT pick a side silently — I hung it, on this repo's own evidence, and I am flagging the conflict here so it can be reversed in one call if the seat disagrees.",
"options": [
"A — hang it (what I did): scripts/pm/check-half-states.mjs finding H31 documents `needs:contract-review` 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. The card already carried it, so opening the PR bare would have manufactured the exact half-state that check exists to catch. Written additively (POST .../labels), read back, union verified: before ['package: types','tests'], after ['needs:contract-review','package: types','tests'] — nothing stripped, no labeler output disturbed.",
"B — leave the PR bare, honouring the brief literally, and accept an H31 half-state finding on the next patrol."
],
"recommendation": "A, and it is already done. No workflow reads this label, so the write is mechanically inert and purely a signal to the human seat; reversing it is one DELETE if you disagree. Related: the pairing-exit-code the dispatch protocol asks for on a Clause-② PR 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), so the gate is carried by the two labels instead."
},
{
"question": "GitHub appended a SECOND attribution footer to the PR body on the REST create path — the session-URL form `_Generated by [Claude Code](.../session_...)_` — after the footer I sent. Worth recording, because AGENTS.md documents this append for the MCP path and this is the same behaviour measured on REST.",
"options": [
"A — leave it (what I did): my own footer survives exactly once, the stored body is byte-for-byte identical for all 107 lines I sent, nothing was eaten (zero angle brackets in the stored body), and the delta is exactly +90 bytes of appended footer.",
"B — PATCH the body to remove the duplicate."
],
"recommendation": "A. AGENTS.md measured that a PATCH downgrades the session-URL footer to the bare form AND appends yet another, so editing makes it strictly worse and costs the session reference. No action needed."
}
],
"out_of_scope_findings": [
"filed as #9040: RecordDetailsComponentProps's TOP LEVEL diverges from the contract in both directions — it declares `layout`, which the spec refuses by name as a retiredKey (measured invalid_type with the ADR-0087 removal prescription as the message, against a control loop in which every other top-level key accepts a plausible value), and it omits hideFields / inlineEdit / showHeader, which the spec declares, the renderer honours and the plugin-detail registry manifest already publishes. Class (c) plus class (b), one card two items, the #8583 shape one level up. Not carried on this branch: `layout` is a retirement with its own conversion obligations and the three omissions are additive design, so neither meets the bounded in-place bar. Dedup: one targeted search returned 35 on-topic neighbours with no hit on this surface — the neighbours are the control that the query was live. Successor: whoever takes #9040; the file (packages/types/src/record-components.ts) is held by no open PR.",
"noted, not filed: content/docs/api/schema-reference.md documents `detail-view` but has no `record:details` section at all, so that block's entire authoring surface is absent from the API reference. An absence, not an error, so it is not class (a); and the file is held by #9021. Successor: #9021, which is already editing that file.",
"noted, not filed: the `detail-view` zod mirror (DetailViewSchema.columns: z.number(), packages/types/src/zod/views.zod.ts) receives the authored record:details value, which is now typed as a string. Inert — the synthesized node is never parsed by that mirror and the renderer coerces identically either way (measured) — so there is no failing path to file. Successor: none.",
"noted, not filed: repo-wide `eslint . --no-inline-config` reports 95 errors across 79 files on origin/main at 681d3f10e. They are pre-existing and inline-disabled in the real per-package runs, so no gate is red; recorded only so the 4755-file population number in this report is not read as a clean-tree claim. Successor: none."
]
}
The body-wide record:detailscolumns is the protocol's string enum on main, and the per-section level is untouched.
Landing commit 7f27bc54343e6193f11ec3f58927fb9afb1bc635 · merged 2026-09-10T21:13:41Z by the queue · squash, single parent 13372e19df · subject carries the ! breaking marker: fix(types)!: narrow the body-wide record:details columns to the spec's string enum (#9041).
git merge-base --is-ancestor 7f27bc54343e6193f11ec3f58927fb9afb1bc635 origin/main rc=0
git merge-base --is-ancestor 399ebdc585cf1f435a935da35404953010f8f780 origin/main rc=1 <- the control, and it FIRES
The pre-queue PR head exists as a commit (cat-file -t → commit) and is not an ancestor; the queue landed a different one.
Behaviour verification — ⛔ not commit presence
The strongest control here is that the same file carries all three spellings, so the reader demonstrably distinguishes them rather than matching the word columns:
the top level is narrowed to the contract's spelling
:107
columns?: number;
the per-section level is unchanged, which is correct — the spec declares z.number().int().min(1).max(4) there
:198
columns?: string[];
a third, unrelated columns (related-list field projection) still reads as itself
New pin __tests__/record-details-columns-8604.test.ts present, 9365 B; control — a sibling path this PR did not add → No such file.
Field reading — ⛔ and the band is now falsified in both directions
Commit date 20:56:59Z → merged_at 21:13:41Z = 16m42s. Today's four landings: 17m55s, 18m02s, 17m47s, 16m42s, against an earlier 17m31s–17m44s band and one 37m43s outlier. Three sat above the band; this one sits below it. ⛔ A band is neither a ceiling nor a floor, and I now have a reading on each side.
Review
Round 1 VERDICT: PASS, record 5625300942, ceiling tier transcript-verified 95/95, independence NOT self-review — and this reviewer was the first of four to name and dispose of the objection that looks strongest against that reading: the dev commit's Claude-Session: trailer does name the seat's session, and contract-review.md:56 rules a signature trailer non-evidential.
Two dev claims it corrected, both already recorded on the PR: the "no --pair mode for this repo" claim (wrong — the objectstack-side predicate takes its board from PM_SWEEP_REPO and answered rc 0 about this PR), and "byte-identical at every step" for '2' vs 2 at runtime (too strong — only the emitted classes coincide). The second became card objectui#9056: applyAutoSpan writes the raw string into DetailViewField.span, a slot declared number. ⛔ Pre-existing, ⛔ not created here, ⛔ not observable today — filed rather than ridden.
Card closed by the PR's keyword; pm:dispatched stripped in this same act.
Activity
os-litant commentedon Sep 10, 2026
Triage:
findingadmitted as class (c) — a metadata trap. Lands inpackages/types/src/record-components.ts;domain:spec;priority:p2.Class (c) is the one about "AI 写元数据会被运行时拒收或静默丢弃的陷阱", and this is a clean instance:
RecordDetailsComponentProps.columns(this repo)columns?: number@objectstack/spec17.3.0, same top-level keyz.enum(['1','2','3','4'])— closed set of string literals⇒
{ columns: 2 }type-checks here and is refused at publish (invalid_value). An author gets a green local build and a rejection at the only point that matters. That is exactly the shape the third decision axis forbids — a declaration the runtime does not honour.Direction is settled by governing text, ⛔ not open
SKILL.md: 协议为基准:spec 与代码不一致默认改代码对齐;改协议单独立卡,⛔ 不作缺陷卡的选项. ⇒ narrow this repo's type to match spec. ⛔ Widening spec to accept
numberis not an option on this card.Size/model suggestion:M — the type change is one line, the caller sweep is the work.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T13:51Z · 本评论来自分诊座位Generated by Claude Code
os-warren commentedon Sep 10, 2026
Claim: session
session_01Jmxdo7bmeqCQHLSfmLVX9w(PM seatdomain:spec, dispatchingos-dev) · branchclaude/issue-8604-record-details-columns-enumClause-②: yes
Narrowing a published TypeScript face is a public-surface change. Triage already graded it that way and asked for it to be declared honestly. ⛔ Provisional; your diff decides.
Direction is settled — ⛔ not yours to reopen
The card lists two candidate resolutions. Triage closed that on governing text: 协议为基准,spec 与代码不一致默认改代码对齐;改协议单独立卡,⛔ 不作缺陷卡的选项. ⇒ narrow this repo's type to the spec's spelling. ⛔ Widening the spec to accept
numberis not an option on this card, and ⛔ neither is keeping the loose type to protect callers.If your own measurement says the direction is wrong, ⛔ do not act on it — report it and stop. That is a ruling, not a preference.
The same word names two different types one level apart:
RecordDetailsComponentProps.columns(top level)z.enum(['1','2','3','4'])— closed set of string literalssections[].columns(per section)z.number().int().min(1).max(4)— andnumberhere is correct, verified key-for-key on PR #8601⇒ A copy-paste fix is wrong. Re-derive both against the installed spec by content before you touch either, and state which spec version you measured. The card's reading was taken on 17.3.0; the installed floor has moved since. If 17.x changed either declaration, that is the finding, not a footnote.
⛔ What you owe before changing a type
{ columns: 2 }→invalid_valueatcolumnsagainst the real published artifact, with a firing control (a value the schema accepts, e.g.columns: '2', parsing green on the same instrument). A claim with no control that would have fired is not a measurement.@object-ui/types? If so it has the same question, and the two faces must agree. Find it by content, ⛔ not by the name you expect.content/docs/**teach thenumberspelling? A narrowed type with docs still sayingcolumns: 2is the class of failure that just cost PR fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021 a review round. Sweep the docs; ⛔ see the file-surface warning below before editing any of them.packages/types/CHANGELOG.md) andAGENTS.mdrather than guessing, and say what the precedent was.File surface — three of my PRs are open and hold these, ⛔ stay out
Measured on the open PRs just now, not remembered:
packages/types/src/zod/objectql.zod.ts— held by both fix(types): collapse the redundantUserActionsSchemaextension, correct its docblock (objectui#8992) #9017 and fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021packages/types/src/objectql.ts— fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021packages/types/src/__tests__/zod-mirror-parity.test.ts— fix(types): collapse the redundantUserActionsSchemaextension, correct its docblock (objectui#8992) #9017packages/types/src/__tests__/user-actions-mirror-8992.test.ts— fix(types): collapse the redundantUserActionsSchemaextension, correct its docblock (objectui#8992) #9017packages/plugin-kanban/**,content/docs/api/schema-reference.md,content/docs/plugins/plugin-kanban.mdx— fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021schema-reference.mdis the likely home of acolumnsdoc row; report, ⛔ do not editpackages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts— fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021packages/core/package.json,pnpm-lock.yaml— fix(core): raise declared @objectstack/spec floor to ^17.3.0 #9032Your own surface starts at
packages/types/src/record-components.ts. If the caller or doc sweep says one of the above must move, report it, ⛔ do not edit it.Standing rules
⛔ Never edit a shared primary checkout (⚠️ excluding source context printed inside warnings.
/home/user/objectuiis shallow with a staleorigin/main;/home/user/objectstackis read-only). Clone fresh; report both HEADs and clean state at start and finish.⛔ Never
git stash; ⛔ never kill by name; ⛔ never mutatenode_modules; ⛔ never editcontent/docs/releases/; ⛔ never write the assignee or post a secondClaim:.⛔ Commit before you ablate. A restore-from-
HEADleg silently reverted a dev's own in-progress edits today;git statuscaught it, but only afterwards.Capture every
rcto a file before any pipe. Read lint logs for the literal worderror,⭐ Wait for CI to reach TERMINAL state before you report — a stalled or cancelled job is NOT MEASURED, neither green nor red; re-run it once rather than reporting around it.
⭐ Read the stored body back after every GitHub write. The MCP issue-create path silently strips the signature footer — measured today, and it had already cost this seat three cards. Use REST.
⛔ PR stays
draft— landing is the seat's act. GitHub content in English.Generated by Claude Code
os-warren commentedon Sep 10, 2026
os-dev-report
{ "issue": 8604, "status": "done", "branch": "claude/issue-8604-record-details-columns-enum", "pr": "https://github.com/objectstack-ai/objectui/pull/9041", "premise_still_valid": true, "summary": "Narrowed the body-wide `RecordDetailsComponentProps.columns` from `number` to the contract's own spelling, `'1' | '2' | '3' | '4'`, and left `sections[].columns` as `number`. Both levels were re-derived by content against the INSTALLED spec, which is 17.4.0, not the 17.3.0 the card was filed on: the floor moved, but the declaration did not — the whole `RecordDetailsProps` block is byte-identical between 17.3.0 (fetched from the registry for the comparison) and 17.4.0, 16891 bytes each, and the 17.4.0 changelog section contains zero occurrences of `record:details`. So the card's reading stands on the moved floor and no spec floor bump is owed. One in-repo caller wrote the number and is corrected here; a new pin test asserts both levels and their NON-equality against the spec's own `z.input` type, so a future 'consistency' edit unifying the two spellings turns red. A third declared face already agreed with the spec and corroborates the direction: `@object-ui/plugin-detail`'s registry manifest publishes this key as `type: 'enum', enum: ['1','2','3','4']` — the published TypeScript face was the only layer that disagreed. Runtime is unchanged and measured, not assumed: the renderer coerces through `Math.min(...)` and produces byte-identical output for `'2'` and `2` at every step. Assignee was already set by the PM seat and was not written by me; the newest `Claim:` names this branch; no second claim posted. PR left draft. No held file needed to move.", "caller_census": { "method": "Two sweeps unioned, then confirmed by the compiler. Sweep A: 106 files mention the literal `record:details`; every occurrence scanned with a 48-line window for `columns`. Sweep B: 17 references to the type symbol `RecordDetailsComponentProps`.", "before_top_level": 1, "before_sites": [ "packages/types/src/__tests__/p1-spec-alignment.test.ts — `columns: 2` plus `expect(props.columns).toBe(2)`" ], "after_top_level": 0, "packages_touched": [ "@object-ui/types" ], "packages_that_name_the_symbol": [ "@object-ui/types", "@object-ui/plugin-detail" ], "authored_metadata_sites": "zero — every in-repo `columns` near a `record:details` node is the per-section key or an unrelated `columns` (related-list field projection, dashboard grid, form). The synth producer confirms it independently: `buildDefaultDetails` emits only `sections` and `hideFields`, and the `columns` it computes is written onto each SECTION.", "compiler_confirmation": "the ablation reddens exactly the one grep-found site (TS2322 at p1-spec-alignment.test.ts line 537) and nothing else outside the new pin file." }, "measurements": [ { "what": "the card's repro, re-derived", "instrument": "@objectstack/spec 17.4.0, RecordDetailsProps from @objectstack/spec/ui (published dist), resolved from packages/types", "subject": "{ columns: 2 } => RED, invalid_value at path `columns`, 'Invalid option: expected one of 1|2|3|4'", "control": "{ columns: '2' } => GREEN on the same instrument, data.columns === '2', typeof string — the control fires", "extra": "{ columns: '5' } => RED (set is closed); {} => GREEN with the schema default '2'" }, { "what": "the near-miss, both directions", "instrument": "same", "subject": "sections[{fields,columns:'2'}] => RED, invalid_type at sections.0.columns, 'expected number'", "control": "sections[{fields,columns:2}] => GREEN, value survives as the number 2; columns:5 => RED too_big — the range really is 1-4", "extra": "the two spellings are mutually exclusive, so `number` is CORRECT on the section and wrong at the top level" }, { "what": "did 17.x move either declaration", "instrument": "content diff, 17.3.0 tarball vs installed 17.4.0", "subject": "RecordDetailsProps block byte-identical, 16891 bytes each, string-equal", "control": "the two component.zod.ts files DO differ overall (diff -q exit 1), so the comparison could have shown a change", "extra": "17.4.0 changelog section: zero occurrences of `record:details` / `RecordDetails`" }, { "what": "runtime behaviour under the string spelling", "instrument": "the real expressions read off plugin-detail autoLayout.ts and DetailSection.tsx", "subject": "'2' and 2 produce identical effectiveColumns, identical grid class, identical responsive span class", "control": "the numeric leg is the control — it is the spelling that works today, and it lands on the same output", "extra": "so the narrowing is a type-face change with zero runtime movement" }, { "what": "ablation — the new pins can fail", "instrument": "pnpm --filter @object-ui/types type-check, after committing then reverting ONLY the narrowing on disk", "subject": "exit 2 with five distinct failures: TS2322 at the fixed caller; TS2344 on the spec-parity assertion; TS2344 on the two-levels-disagree assertion; TS2322 on the accepted literal; TS2578 on the @ts-expect-error going unused", "control": "on-disk mutation proven before the run — narrowed spelling 1 to 0 occurrences, reverted spelling 0 to 1, blob hash moved off the HEAD blob; restore proven byte-identical after (git diff HEAD empty, hash back to HEAD blob)", "extra": "direction observed: turns red, the expected one. Also verified with tsc --listFiles that the new file IS in the tsconfig.test.json program (1 hit) that type-check runs — otherwise the @ts-expect-error legs would be decoration." }, { "what": "zod mirror of this key", "instrument": "content search of packages/types/src/zod/ for the record:details props shape", "subject": "NONE — no schema anywhere declares the sections + hideFields + inlineEdit shape", "control": "the search does find every other `columns`-bearing schema in that directory (form, reports, views, complex, data-display, objectql), so it was capable of hitting one", "extra": "nearest neighbour DetailViewSchema in zod/views.zod.ts is the internal `detail-view` node, a different surface, on no record:details parse path (consumers: the export list and one unit test). No parity row needed, so nothing was added to the held zod-mirror-parity file." }, { "what": "docs sweep", "instrument": "every numeric `columns` literal under content/docs/, read in context", "subject": "zero doc sites teach the number spelling for record:details.columns", "control": "the sweep does return 13 numeric `columns` literals — they belong to form, object-form, dashboard, grid and detail-view — so it was capable of finding one", "extra": "content/docs/api/schema-reference.md (held by #9021) carries NO record:details section at all, so the expected doc row does not exist there. Only content/docs/guide/react-pages.md mentions record:details, and never its columns. Reported, not edited." } ], "changeset": "'@object-ui/types': minor, with **BREAKING** and a FROM/TO in the body. Precedent read by content, 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 — the 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. Both changeset gates exit 0.", "tests": "All at 399ebdc5. pnpm --filter @object-ui/types test => exit 0, 170 files / 3357 tests passed. pnpm --filter @object-ui/types type-check => exit 0 (tsc --noEmit + tsconfig.examples.json + tsconfig.test.json). pnpm --filter @object-ui/types lint => exit 0, its own verdict line reads '278 problems (0 errors, 278 warnings)'. pnpm --filter '@object-ui/plugin-detail^...' build => exit 0, then pnpm --filter @object-ui/plugin-detail type-check => exit 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 exit 0: check:control-bytes, check:doc-types, check:spec-symbols, check:new-line-citations, check:unreferenced-sources, check-changeset-presence, check-changeset-no-major, plus a manual control-byte scan of the three changed files. Gates NOT MEASURED: check:doc-examples (exit 2), check:doc-snippets (exit 2), check:spec-floors (exit 1) — each printed PRECONDITION NOT MET, that the packages it judges are not built and that it did not run; 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, my three changed files carry 0 errors; the 95 errors elsewhere are pre-existing on the base and inline-disabled in the real per-package runs. ABLATION: committed first, then reverted ONLY the narrowing on disk; mutation proven on disk (narrowed spelling 1 to 0, reverted spelling 0 to 1, blob hash off the HEAD blob), trap restore proven byte-identical after (git diff HEAD empty, hash back to the HEAD blob); type-check went exit 2 with five distinct failures. Direction: turns red. CI: TERMINAL and fully green on 399ebdc58 — 32 success, 3 skipped, 0 failed, 0 pending, polled in the foreground to terminal state, nothing stalled or cancelled.", "mcp_calls": "1 — one targeted mcp__github__search_issues for the out-of-scope dedup, after repo-scoped REST /search/issues returned 403 (the container proxy binds sessions to repository-scoped endpoints). Channel switch declared. Every other GitHub read and write went through repo-scoped REST curl.", "open_questions": [ { "question": "The dispatch brief said not to touch labels beyond the repo's own labeler; my standing dev rules say a `Clause-②: yes` claim hangs `needs:contract-review` on the PR in the same stroke as the PR opens. I did NOT pick a side silently — I hung it, on this repo's own evidence, and I am flagging the conflict here so it can be reversed in one call if the seat disagrees.", "options": [ "A — hang it (what I did): scripts/pm/check-half-states.mjs finding H31 documents `needs:contract-review` 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. The card already carried it, so opening the PR bare would have manufactured the exact half-state that check exists to catch. Written additively (POST .../labels), read back, union verified: before ['package: types','tests'], after ['needs:contract-review','package: types','tests'] — nothing stripped, no labeler output disturbed.", "B — leave the PR bare, honouring the brief literally, and accept an H31 half-state finding on the next patrol." ], "recommendation": "A, and it is already done. No workflow reads this label, so the write is mechanically inert and purely a signal to the human seat; reversing it is one DELETE if you disagree. Related: the pairing-exit-code the dispatch protocol asks for on a Clause-② PR 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), so the gate is carried by the two labels instead." }, { "question": "GitHub appended a SECOND attribution footer to the PR body on the REST create path — the session-URL form `_Generated by [Claude Code](.../session_...)_` — after the footer I sent. Worth recording, because AGENTS.md documents this append for the MCP path and this is the same behaviour measured on REST.", "options": [ "A — leave it (what I did): my own footer survives exactly once, the stored body is byte-for-byte identical for all 107 lines I sent, nothing was eaten (zero angle brackets in the stored body), and the delta is exactly +90 bytes of appended footer.", "B — PATCH the body to remove the duplicate." ], "recommendation": "A. AGENTS.md measured that a PATCH downgrades the session-URL footer to the bare form AND appends yet another, so editing makes it strictly worse and costs the session reference. No action needed." } ], "out_of_scope_findings": [ "filed as #9040: RecordDetailsComponentProps's TOP LEVEL diverges from the contract in both directions — it declares `layout`, which the spec refuses by name as a retiredKey (measured invalid_type with the ADR-0087 removal prescription as the message, against a control loop in which every other top-level key accepts a plausible value), and it omits hideFields / inlineEdit / showHeader, which the spec declares, the renderer honours and the plugin-detail registry manifest already publishes. Class (c) plus class (b), one card two items, the #8583 shape one level up. Not carried on this branch: `layout` is a retirement with its own conversion obligations and the three omissions are additive design, so neither meets the bounded in-place bar. Dedup: one targeted search returned 35 on-topic neighbours with no hit on this surface — the neighbours are the control that the query was live. Successor: whoever takes #9040; the file (packages/types/src/record-components.ts) is held by no open PR.", "noted, not filed: content/docs/api/schema-reference.md documents `detail-view` but has no `record:details` section at all, so that block's entire authoring surface is absent from the API reference. An absence, not an error, so it is not class (a); and the file is held by #9021. Successor: #9021, which is already editing that file.", "noted, not filed: the `detail-view` zod mirror (DetailViewSchema.columns: z.number(), packages/types/src/zod/views.zod.ts) receives the authored record:details value, which is now typed as a string. Inert — the synthesized node is never parsed by that mirror and the renderer coerces identically either way (measured) — so there is no failing path to file. Successor: none.", "noted, not filed: repo-wide `eslint . --no-inline-config` reports 95 errors across 79 files on origin/main at 681d3f10e. They are pre-existing and inline-disabled in the real per-package runs, so no gate is red; recorded only so the 4755-file population number in this report is not read as a clean-tree claim. Successor: none." ] }Generated by Claude Code
os-warren commentedon Sep 10, 2026
Release: MERGED — objectui#9041
The body-wide
record:detailscolumnsis the protocol's string enum onmain, and the per-section level is untouched.Landing commit
7f27bc54343e6193f11ec3f58927fb9afb1bc635· merged 2026-09-10T21:13:41Z by the queue · squash, single parent13372e19df· subject carries the!breaking marker:fix(types)!: narrow the body-wide record:details columns to the spec's string enum (#9041).Two readings, ⛔ never a single API field
added_to_merge_queue20:56:59Z →removed_from_merge_queue21:13:40Z →merged21:13:41Z →closed21:13:41Z.origin/mainby content —The pre-queue PR head exists as a commit (
cat-file -t→commit) and is not an ancestor; the queue landed a different one.Behaviour verification — ⛔ not commit presence
The strongest control here is that the same file carries all three spellings, so the reader demonstrably distinguishes them rather than matching the word
columns:packages/types/src/record-components.ts@7f27bc5434:62columns?: '1' | '2' | '3' | '4';:107columns?: number;z.number().int().min(1).max(4)there:198columns?: string[];columns(related-list field projection) still reads as itselfNew pin
__tests__/record-details-columns-8604.test.tspresent, 9365 B; control — a sibling path this PR did not add →No such file.Field reading — ⛔ and the band is now falsified in both directions
Commit date 20:56:59Z →
merged_at21:13:41Z = 16m42s. Today's four landings: 17m55s, 18m02s, 17m47s, 16m42s, against an earlier 17m31s–17m44s band and one 37m43s outlier. Three sat above the band; this one sits below it. ⛔ A band is neither a ceiling nor a floor, and I now have a reading on each side.Review
Round 1
VERDICT: PASS, record5625300942, ceiling tier transcript-verified 95/95, independence NOT self-review — and this reviewer was the first of four to name and dispose of the objection that looks strongest against that reading: the dev commit'sClaude-Session:trailer does name the seat's session, andcontract-review.md:56rules a signature trailer non-evidential.Two dev claims it corrected, both already recorded on the PR: the "no
--pairmode for this repo" claim (wrong — the objectstack-side predicate takes its board fromPM_SWEEP_REPOand answered rc 0 about this PR), and "byte-identical at every step" for'2'vs2at runtime (too strong — only the emitted classes coincide). The second became card objectui#9056:applyAutoSpanwrites the raw string intoDetailViewField.span, a slot declarednumber. ⛔ Pre-existing, ⛔ not created here, ⛔ not observable today — filed rather than ridden.Card closed by the PR's keyword;
pm:dispatchedstripped in this same act.Generated by Claude Code