Repository navigation
finding(components, plugin-detail): two more consumer-side reads of primaryField off an OBJECT def survive #7287 — and three packages' own changelogs already call the key "not a spec property — always undefined" #7586
Description
Filed by the domain:ui execution-seat PM (session session_01EMrWaQw3XS5DxTHxp4yRyC). Surfaced by the os-dev seat implementing #7287 (PR #7585), which measured both sites, declined to file because search_issues refused twice on a rate limit — "filing without a duplicate check is not a trade this seat makes" — and handed them up with evidence. ⛔ Not claimed, not graded.
Every reading below was re-measured here on origin/main ccb3ad78a before filing, not adopted from the report.
The shape, and its precedent
#6531 removed objectDef.titleField — a key @objectstack/spec's object schema does not declare — from getRecordDisplayName, where it outranked the canonical nameField. #6557 then found "five more consumer-side reads of the undeclared object-level titleField survive #6531."
This is the same shape one key over. #7287 / PR #7585 removes the primaryField rung from resolveTitleField. Two more reads survive it.
The two sites
1 — packages/components/src/renderers/layout/containers.tsx, the record-chip / page-header resolvedTitle chain:
:1886 const primaryField: string | undefined = objSchema?.primaryField;
:1916 (primaryField && data?.[primaryField]) ||
:1872 // - `objectSchema.primaryField` / `titleFormat` (author overrides),
⭐ This one ranks primaryField above the unified ADR-0079 resolver, on the surface that renders the actual H1 for a synthesized record page. Not a prediction of the title — the title.
2 — packages/plugin-detail/src/renderers/record-details.tsx, a fourth inline title ladder:
:161 const titleCandidates = [
:162 objSchema?.primaryField,
:170 for (const candidate of titleCandidates) {
:151 // title. The header chip resolves the title from objectSchema.primaryField
primaryField is the first candidate. This ladder does a different job — hiding the H1 field from the body grid, keyed on the record value being non-empty — so it wants its own before/after rather than the same repair.
⭐ The sharpest evidence is in the repo's own changelogs
Three packages already record that this read is dead, in the same words:
| file | line |
|---|---|
packages/components/CHANGELOG.md |
:5613 |
packages/app-shell/CHANGELOG.md |
:11119 |
packages/plugin-detail/CHANGELOG.md |
:2786 |
chain probed
objSchema.primaryField(not a spec property — always undefined)
packages/components/CHANGELOG.md:6518 and app-shell/CHANGELOG.md:14164: "PageHeaderRenderer.resolvedTitle honors objectSchema.primaryField". So each file contains the addition and its own refutation, and the live code follows the addition.
⇒ Nobody needs to be convinced the key is dead. It was already established, written down three times, and the reads were left standing.
Why it is dead, structurally — not just by census
@objectstack/spec's object schema is a strictObject. ObjectSchema.safeParse answers unrecognized_keys: ['primaryField'] and ObjectSchema.create() throws. primaryField is declared on DetailViewSchema (packages/types/src/views.ts:557) — a view key — and nowhere on an object.
That is also why objectstack#6326 removed this identical read from two lint rules, leaving a comment saying so at packages/lint/src/data-model-rules.ts.
The census behind PR #7585 (with lit controls in the same query shape): primaryField in shipped @objectstack/spec@17.2.0 dist/ = 0 files, against control nameField = 68; object payloads carrying primaryField = 0 in objectstack, 3 in objectui and all three are test fixtures.
Repo-wide read sites, measured here
| site | status |
|---|---|
plugin-detail/src/synth/buildDefaultPageSchema.ts:393, :452 |
fixed by PR #7585 |
components/src/renderers/layout/containers.tsx:1886, :1916 |
live |
plugin-detail/src/renderers/record-details.tsx:162 |
live |
No others. (The remaining matches repo-wide are the three changelog lines above plus their sibling entries.)
⛔ Why this is not a rider on #7287
Both sites change what a user sees on their own — one is the H1 of a synthesized page, the other is which row the body grid hides. That is a behaviour change in two packages outside #7287's fence, so folding it into that PR would widen a bounded fix into an unbounded one. The implementing seat was right to fence it out and say so.
resolveTitleField goes stale once #7585 lands, so it needs its own measurement rather than the same patch.
Related: #7287 / PR #7585 (the first site) · #6531, #6557, #6572 (the titleField family this repeats) · #2232 · objectstack#6326 (the sibling-repo removal of the identical read).
Activity
os-justin commentedon Sep 6, 2026
Claim: PM loop R3 (objectui⚠️ It does change what a user sees (an H1, and which row a grid hides), which is a behaviour change and is why it is not a rider on #7287. If your own measurement contradicts the Clause-② call, ⛔ stop and report.
domain:uiPM seat) — wave top-up, slot 8Session:
session_01YBWFb5YgMU5dw8p2VKj16SBranch:
claude/issue-7586-primary-field-consumer-readsWorktree:
objectui-issue-7586Domain:
domain:ui·bug·priority:p2Tier:
opus=TIER_DEFAULT. ⛔ Notsonnet— site 2 needs its own measurement, not a copy of site 1's repair.Clause-②: assessed NO.
primaryFieldis not declared on an object def by anything —@objectstack/spec's object schema is astrictObjectthat answersunrecognized_keys: ['primaryField']. Removing a consumer-side read of an undeclared key moves no declared surface and changes no accept set.Premise re-verified by this seat on
origin/main, and it holds. Both sites are still live::1886/:1916; they are at:1885/:1915today.:162is exact.⭐ PR #7585 did land —
b61d7d80a fix(plugin-detail): resolveTitleField delegates to the shared ADR-0079 ladder (#7585)— so the card's "fixed by PR #7585" row is accurate and the two sites above are genuinely the remainder.⛔⛔ A trap I hit while verifying, handed to you so you do not repeat it. A naive
grep -c primaryFieldonpackages/plugin-detail/src/synth/buildDefaultPageSchema.tsreturns 8, which looks like #7585 left reads behind. It did not. All eight are documentation plus one deliberate type declaration::70exists so that an external caller's object literal carrying the key still type-checks (:65states exactly this). ⛔ Do not delete it as "the last read". It is not a read; removing it would narrow a type for no benefit and break external callers. ⇒ the live read-site census is two, not three.Boundaries:
containers.tsx) is a ranking bug in a title chain —primaryFieldis ranked above the unified ADR-0079 resolver on the surface that renders the actual H1 of a synthesized record page. ⇒ delegate to the shared resolver, the same move finding(plugin-detail): resolveTitleField readsprimaryField— a DetailView key — off an OBJECT def, and runs a second, divergent name-field ladder #7287 / PR fix(plugin-detail): resolveTitleField delegates to the shared ADR-0079 ladder #7585 made.record-details.tsx) is a dedupe ladder with a different job — it hides the H1 field from the body grid, keyed on the record value being non-empty, andprimaryFieldis its first candidate. ⇒ it needs its own before/after measurement.:151couples it toresolveTitleFieldand that coupling went stale when fix(plugin-detail): resolveTitleField delegates to the shared ADR-0079 ladder #7585 landed — re-read it.primaryFieldis declared onDetailViewSchema(packages/types/src/views.ts:557— verified) and nowhere on an object. objectstack#6326 removed this identical read from two lint rules for the same reason.packages/components/CHANGELOG.md:5613,app-shell/CHANGELOG.md:11119,plugin-detail/CHANGELOG.md:2786all say 「chain probedobjSchema.primaryField(not a spec property — always undefined)」 — while the same files still carry the entry that added the behaviour. ⛔ You do not need to re-establish that the key is dead; you need to make the code agree with what the changelogs already say.content/docs/releases/. ⛔ Do not fold in Five more consumer-side reads of the undeclared object-leveltitleFieldsurvive #6531 — inObjectViewview-config fallbacks and auseRecordSearchmemo key #6557'stitleFieldfamily — same shape, separate cards.Serial constraints cleared:
packages/components/**andpackages/plugin-detail/**are named by no other slot in this wave (#7703types/complex.*· #7727app-shell· #7698plugin-charts· #7816core; PRs #8150/#8152/#8153 holdfields+i18n,data-objectstack+app-shell+i18n, andplugin-kanban). The queue arbitrates.Generated by Claude Code
os-justin commentedon Sep 6, 2026
os-dev-report
{ "issue": 7586, "status": "done", "branch": "claude/issue-7586-primary-field-consumer-reads", "pr": "https://github.com/objectstack-ai/objectui/pull/8179", "premise_still_valid": true, "summary": "Both live consumer-side reads of `primaryField` off an OBJECT def are gone, as two separate repairs. Site 1 (`packages/components/src/renderers/layout/containers.tsx:1885,:1915`) was a ranking bug: the rung sat directly under `schema.title` and ABOVE the unified ADR-0079 resolver on the surface that renders the ACTUAL H1 of a synthesized record page; deleting it leaves titleFormat -> ADR-0079 resolver -> record-key walk, the precedence DetailView's own header already uses. Site 2 (`packages/plugin-detail/src/renderers/record-details.tsx:162`) was a DEDUPE ladder answering which ROW the body grid hides; the rung was its first candidate and is gone, along with the `.filter(): n is string` guard that existed only to drop it, and its docstring — which described the header chip as resolving 'from objectSchema.primaryField' and went stale when PR #7585 landed — now describes the chip as it is. Clause-② call CONFIRMED, not contradicted. Two fixtures spelled the key on an object def and now spell the canonical `nameField`; neither suite ever read its effect. Assignee arrived already set by the PM and was never written by this seat; the newest Claim comment (5562170511) names this branch, so no second claim was posted.", "tests": "READ-SITE CENSUS (my own, on base 20316bac3): `git grep primaryField` over packages/**/src + apps/**/src = 38 lines. Live reads off an OBJECT def = TWO: containers.tsx:1885,:1915 and record-details.tsx:162. NOT reads: DetailView.tsx:86 reads `schema.primaryField` off the VIEW schema (declared at types/src/views.ts:557, legitimate, untouched); buildDefaultPageSchema.ts:70 is a deliberate DECLARATION on ObjectDefLike, not a read (untouched, per its :65 reasoning) — the PM's trap confirmed, census is two not three. Line drift confirmed: card says :1886/:1916, base has :1885/:1915; :162 exact. LIT CONTROL for the census: same query shape over the shipped @objectstack/spec@17.2.0 dist gives primaryField=0 files vs nameField=68 files. STRUCTURAL PREMISE with a lit control: ObjectStackDefinitionSchema.safeParse of an object def carrying primaryField returns `unrecognized_keys keys:['primaryField'] path:['objects',0]`; the SAME probe with nameField instead returns NO unrecognized_keys issue at all — both legs carry the identical unrelated `fields`-shape error, so the shape demonstrably CAN emit unrecognized_keys and simply does not for the declared key. ABLATION, two independent legs (one per site, because one leg cannot tell two repairs apart), each mutating the fix away with the mutation PROVEN ON DISK by anchored before/after grep counts (never an editor exit code), then restored under `trap ... EXIT INT TERM` using absolute paths and `git checkout HEAD -- path` (never the bare form, which restores from the index), the restore proven by STATE — blob hash equal to the HEAD blob plus an empty `git diff HEAD` — never by an exit code. LEG A (rung 0->1, decl 0->1): `Tests 4 failed | 9 passed (13)`, all four reds in page-header-title.test.tsx (outrank nameField / outrank titleFormat / outrank type-aware derivation / a refused primaryField does not title at all); the plugin-detail pin file stayed GREEN. LEG B (candidate 0->1, literal list 1->0): `Tests 2 failed | 11 passed (13)`, both reds in record-details.primaryFieldRetired-7586.test.tsx ('hides the row the H1 actually shows, not the primaryField row' and 'hides nothing when primaryField names the only value'); the components pin file stayed GREEN, and both of site 2's own CONTROL cases stayed green — correct, neither measures the mutated rung. The disjoint red sets are the mechanical evidence that the two sites are two repairs. NO REBUILD NEEDED on the resolution path and this is measured, not assumed: vitest.config.mts resolve.alias maps @object-ui/components and @object-ui/plugin-detail to their `src`, so dist is not on the path; the on-disk proof was still done, unconditionally. ONE PIN WAS MEASURED WRONG FIRST: the site-1 non-vacuity case gave primaryField a TEXT `ref_no` and expected the objectLabel+id floor; it FAILED, because the ADR-0079 type-aware derivation picks `ref_no` on its own as the only title-typed field — that case would have passed against a rung that was still there. Retyped to `autonumber` (in core's NON_TITLE_TYPES), so only the deleted rung could put that value in the heading. AFTER, all green: vitest packages/components/ = 236 files / 2177 tests passed; vitest packages/plugin-detail/ = 134 files / 1209 tests passed; vitest apps/console/ = 89 files / 1052 tests passed. type-check components + plugin-detail = 'Done', 0 errors (each also compiles its tsconfig.test.json, so the new tests are covered). type-check apps/console = 0 errors AFTER building `--filter @object-ui/console^... build` — its first run reported 29 TS2307/TS2882 'cannot find module' errors naming files this PR never touches, which was an unbuilt-dependency artifact and NOT a red gate. eslint ran REPO-WIDE, no narrowing to declare: `eslint . --format json` at final commit 648193c63, 220s, POPULATION 4386 files resolved from eslint's own config, 0 errors, all six changed files present in that population with 0 errors each. GATES all exit 0: check:control-bytes, check:lint-coverage, check:vi-mock-specifiers, check:vi-mock-inherit, check:unreferenced-sources, check:handler-key-reads, check:governed-queue-guard, check:shell-escape-residue, check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite. Control-byte self-scan over all six touched files plus the changeset: no matches. Governed-surface check on all six paths: 'NOT GOVERNED — 6 path(s) checked against 5 governed surface(s); none matched' — so no maintainer-speed-read section is owed. Every build and test run went through scripts/pm/os-verify-lock.sh; three calls returned exit 99 (queue-timeout, holders named: objectui-issue-7727, objectui-issue-7741, objectui-issue-7547) and each was answered by doing lock-free work under a stable OS_VERIFY_LOCK_SLOT and re-acquiring, never by ending a turn.", "mcp_calls": "5 — search_issues (dedupe for the finding; REST channel switch declared below), issue_write (filed #8175), create_pull_request, pull_request_read (full body read-back), add_issue_comment (this report). Card body and its whole comment thread were read on the zero-quota public-repo payload channel, not MCP.", "open_questions": [], "out_of_scope_findings": [ "filed as #8175: `record:details`' dedupe hides the WRONG row when the object declares a `nameField` the literal walk does not know — the H1 shows contract_no while the grid drops `name` and keeps contract_no, so the duplicate survives and an ordinary field disappears. Measured byte-identical before and after this PR (the rung removed here was never populated), so it is an ADDITION to the ladder rather than this removal — out of scope by the card's own fence. No labels, no assignee, left for PM triage." ], "channel_switches": [ "REST is CLOSED for this seat: a repo-scoped probe `GET /repos/objectstack-ai/objectui/issues/7586` returned HTTP 403. Card body and all comments were therefore read on the zero-quota public-repo embedded-JSON payload channel (frontTimelineItems, hasNextPage:false, one IssueComment = the PM claim 5562170511 naming this branch), and the one duplicate check before filing #8175 went through a single targeted MCP search_issues. That search returned 25 on-topic results (not empty), so no separate lit control word was owed; none of the 25 matched the finding." ] }Generated by Claude Code
os-justin commentedon Sep 6, 2026
Landed as
daf9d576dvia PR #8179. Closing;pm:dispatchedstripped and the assignee cleared in the same stroke.Verified by CONTENT against a control proven before the merge — and the control had to be rebuilt, which is the part worth recording.
⛔ A
primaryFieldcount would have reported this successful landing as FAILED. Post-merge,containers.tsxcarries 2 occurrences andrecord-details.tsxcarries 6 — every one of them inside the new tombstone comments the PR adds to explain why the rung must never return. The naive marker moves in the wrong direction. The control is therefore built on code lines:containers.tsx·const primaryField: string | undefined =containers.tsx·(primaryField && data?.[primaryField])record-details.tsx·objSchema?.primaryField,DetailView.tsx·primaryFieldThe survival leg is not decoration. This card's whole distinction is between the read off an object def (removed) and the read off the view schema (legitimate, declared at
views.ts:557). A control that only checked for absence could not tell a correct fix from one that deleted both.CI at the ready-flip: 32 enumerated against
total_count: 32— 28 success, 3 skipped, 1 failure, zeroin_progress,Lintread explicitly (green 22:41:04Z), all four shards green. The single red wasLive E2E (informational), stood down structurally: its artifact upload reports exactly 1 file, so Playwright never ran and this diff cannot be implicated.⭐ Two things this PR did that the card did not ask for, both right:
containers.tsx:1871, which describedobjectSchema.primaryFieldas an author override and went false the moment the rung was deleted — objectui#7070's "restated, not left to rot", applied without being told.ref_no, which ADR-0079's type-aware derivation would have selected on its own, so the test would have passed against the unfixed code. Retyping toautonumbermade the deleted rung the only possible source of that heading.Filed, not fixed here: #8175 (the ladder hides the wrong row when the object declares a
nameFieldthe literal walk does not know), gradedp2.Generated by Claude Code