Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

finding(app-shell): the designer's ViewColumnInspector writes back whichever column spelling it found, so editing a stored accessorKey column re-saves a spelling object-grid no longer renders #5344

Closed

Description

@os-support-ai

Found while implementing #5068 (retiring ObjectGrid's undeclared accessorKey / header tolerance branch, and migrating its one in-repo producer bridgeListView). Filed unassigned, not claiming. Observation class — nothing is broken today by this alone.

The fact

packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx is the designer's scoped editor for a selected view column. It reads column identity through both spellings and, on write, preserves whichever one the stored document already used:

// :64
function colFieldKey(c: ViewColumn): string {
  return c.field ?? c.accessorKey ?? '';
}
// :155
const patchIdentity = (updates: Partial<ViewColumn>) => {
  const targetField = 'field' in col || !('accessorKey' in col) ? 'field' : 'accessorKey';
  const targetLabel = 'label' in col || !('header' in col) ? 'label' : 'header';

Its own header comment names the intent: "the column IDENTITY (field key + label) … round-trip through two shapes: the ObjectStack canonical { field, label } and the legacy TanStack { accessorKey, header }."

Why it is worth recording now

#5068 removes the runtime that made the second shape work. After that PR:

  • creating a column in the designer is already canonical and unaffected — makeColumn (previews/view-column-io.ts:60) emits { field } plus label only when the author's label differs from the machine name;
  • editing a column of a stored view that already carries { accessorKey, header } re-saves it in that spelling. The inspector shows the value (it reads both), the author changes the field key, saves — and the column still does not render in an object-grid, because the spec refuses those keys by name (ListColumnSchema is strict: unrecognized_keys ["accessorKey","header"]) and the renderer no longer tolerates them.

So the editor keeps a dialect alive one layer above the renderer that just gave it up. That is the same declared-vs-read divergence #5068 is about, relocated into the authoring tool — where it is arguably worse, since the tool is the surface that is supposed to teach the correct shape.

Note this is a round-trip only: the inspector never invents the undeclared spelling, it only preserves one it was handed. Whether such documents exist in a real tenant is unmeasured here (the in-repo census for #5068 found zero authored accessorKey-shaped object-grid columns across examples/, content/docs/, apps, skills, e2e and package READMEs; every in-repo occurrence belongs to the table / data-table component, which legitimately owns the key).

The fork, unruled

  1. Normalize on write — patchIdentity always writes { field, label } and drops accessorKey / header when it rewrites the column. The designer then migrates legacy documents by the act of editing them, which is the producer-side unification AGENTS.md #0.1 asks for.
  2. Read both, write canonical, and say so — same as (1) plus a visible note in the inspector that the column was migrated.
  3. Leave it — the round trip is harmless as long as nothing renders those columns, and the spelling dies out with the documents.

(1) matches the disposition #5068 inherited from #3951; (3) is defensible only if no such documents exist, which nobody has measured on real data.

Scope note

Out of scope for #5068, whose granted file surface is packages/plugin-grid + packages/react (packages/app-shell/** explicitly excluded). Same family as #5120 (col.accessorKey || col.name in packages/components / plugin-dashboard) and #5340 (bridgeListView, resolved by #5068's PR). This is the fifth site in that family and the only one that is an editor rather than a renderer or a producer.

Reproduce

sed -n '1,20p;60,70p;150,170p' packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx
sed -n '60,70p' packages/app-shell/src/views/metadata-admin/previews/view-column-io.ts   # create path: canonical

Generated by Claude Code

Activity

  1. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    Contributor

    Triage first-touch grading (finding centralized round, 2026-08-20, triage seat session session_016A4EBi3ky1mjTi6vD2kCvA): promote to pm:queue, type Bug. Re-verified on origin/main: ViewColumnInspector.tsx:157 still preserves whichever spelling the stored doc carries, so editing a legacy accessorKey column re-saves a spelling the strict ListColumnSchema refuses and post-#5068 ObjectGrid won't render — the editor keeps alive the dialect the renderer already gave up. Normalize-on-write is decidable now for grid view columns regardless of the #5120 data-table ruling (that contract is already settled: field/label), and fixing the editor shrinks the population #5349 must serve. One-file diff; sequence with/before #5349 as its card already requests.


    Generated by Claude Code

  2. added theissue type on Aug 20, 2026
  3. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 21, 2026
  4. self-assigned this
    on Aug 22, 2026
  5. os-sales commented on Aug 22, 2026

    @os-sales
    Collaborator

    Claim: PM loop round 12
    Session: session_012u2pRjcqAYtoEjgr3wwhnK
    Branch: claude/issue-5344-viewcolumn-identity-writeback
    Worktree: objectui-5344
    Domain: domain:ui (execution seat)

    File surface: packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx + its tests. ⛔ OUT: packages/app-shell/src/views/ObjectView* and the filter-completeness helpers — held by #5025 this round. ⛔ OUT: packages/app-shell/src/index.ts.

    Why this card waited a round: it was deferred from round 11 solely because #5596 was editing this directory's barrel. That PR is enqueued; the collision is cleared. Re-derive the file's state on the current tip — #5596 landed changes in metadata-admin/.

    Clause ②: likely not tripped (a designer inspector, not a published declaration) — but measure it rather than inherit my guess. This seat has stated a reachability fact from memory and been wrong three times in two rounds. The test that decides it: does packages/app-shell/src/index.ts re-export this module, and does the exports map reach it? export * propagates without naming, so a grep for the symbol in a barrel proves nothing.

    The judgement fork, stated up front. The file preserves whichever spelling the stored document already used ('field' in col || !('accessorKey' in col) ? 'field' : 'accessorKey'). #5068 retired ObjectGrid's undeclared accessorKey/header tolerance and migrated its one in-repo producer. If converging the writeback onto the canonical spelling would change what a stored document becomes on next save, that is a data-shape decision, not a refactor — report it with the measurement rather than picking a side.


    Generated by Claude Code

  6. os-sales commented on Aug 22, 2026

    @os-sales
    Collaborator
    {
      "issue": 5344,
      "status": "needs_decision",
      "branch": "claude/issue-5344-viewcolumn-identity-writeback",
      "pr": null,
      "premise_still_valid": true,
      "summary": "Premise re-verified on the current tip (7e811687a): colFieldKey is at :64 and patchIdentity's ternary at :157 (the docblock at :155), quoted code exact, file did not move under #5596. The writeback fork is LIVE and it is a data-shape decision, so per the dispatch I measured it instead of picking: no code change, no PR, clean tree identical to origin/main. The measurement changes the framing of the card. The issue calls the round-trip harmless ('nothing is broken today by this alone'); it is not a round-trip, it is a CLOSED LOOP. A stored column in any non-canonical spelling is already refused by the designer's own live edit-mode gate BEFORE the edit, and is still refused AFTER it, because patchIdentity re-emits the spelling it found. No sequence of inspector edits can turn such a column into a saveable document. Separately, the file also carries the READ half of the same tolerance alias (:64 'c.field ?? c.accessorKey', :68 'c.label ?? c.header') — literally the construct #5068 retired from ObjectGrid, still present one layer up, and the reason the inspector displays a refused column as though it had a valid identity. That read half is not in the issue's three-option list and the ruling needs to cover it. Two in-repo authorities point opposite ways on the write half, which is why this is not mine to pick: plugin-grid/columnSpellingDiagnostics.ts + ObjectGrid.tsx:1592-1625 (#5068 inheriting #3951) say unify at the producer with no consumer-side tolerance, while packages/core/src/utils/column-identity.ts:36-47 says accessorKey is deliberately OUTSIDE the identity fold ('the fold neither reads nor writes it'; canonicalizing across that boundary 'would fossilize the merge') and :160-172 records that for the analogous legacy retirement the repo chose MIRROR-not-delete precisely because columns entries cross into third-party renderers and 'dropping name from under them is a breaking change we have no inventory for'.",
      "tests": "No code changed, so there is no regression surface and I ran no suite beyond the measurement — declared narrowing, not a skipped gate. Environment: pnpm install exit 0; 'pnpm --workspace-concurrency=2 --filter @object-ui/app-shell^... build' BUILD_DEPS_EXIT=0; 'pnpm --filter @object-ui/app-shell build' BUILD_APPSHELL_EXIT=0 (dist/ verified ABSENT pre-build and no tsconfig.tsbuildinfo anywhere under packages/app-shell outside node_modules, so tsc could not skip emit). MEASUREMENT INSTRUMENT (temporary, deleted after the run, never committed — 'git status --short' and 'git diff origin/main --stat' both empty at report time): a vitest file that mounts the real ViewColumnInspector, drives one identity edit, captures the real onPatch payload and runs the resulting document through ViewMetadataSchema. Run from the repo ROOT with the streaming verbose reporter (--reporter=basic no longer exists in vitest 4 and aborts the run before any test executes): 'npx vitest run --reporter=verbose --maxWorkers=2 <file>' -> MEASURE_EXIT=0, vitest's own verdict lines 'Test Files  1 passed (1)' / 'Tests  2 passed (2)'. Only the Radix combobox was doubled (partial vi.mock of ./_shared via importOriginal); patchIdentity and writeColumns ran real. CONTROLS, each capable of failing and measured doing so: (a) canonical {field,label,width} and (b) a bare string column are carried through the SAME edit path and both stay saveable ('becomes parses: true') — and control (a) CAUGHT A REAL INSTRUMENT BUG on the first run, failing because my verdict document put an undeclared 'object' key on the list variant; the numbers below are from the corrected run. Reachability probe controls: grep for a symbol that IS exported (registerMetadataInspector) returns 1 in both dist/index.d.ts and dist/index.js, so the 0 for ViewColumnInspector is absence, not a broken scan; the root specifier resolves while the deep ones do not, so ERR_PACKAGE_PATH_NOT_EXPORTED is the exports map talking, not a broken resolver. Spec strictness measured directly against @objectstack/spec/ui, not inferred: ListColumnSchema.safeParse({accessorKey,header,width}) -> success=false with issues [invalid_type on 'field', unrecognized_keys ['accessorKey','header']]. In-repo census re-run on the tip with fixed-string grep: every accessorKey occurrence under examples/ apps/ content/ skills/ e2e/ belongs to the table / data-table COMPONENT, which legitimately declares the key; content/docs/plugins/plugin-grid.mdx:90 actively teaches 'There is no accessorKey' for ListColumn. Zero authored accessorKey-shaped view columns — #5068's census re-confirmed. (One of my own greps hit the metacharacter trap the dispatch warned about — an unescaped brace in 'disabled=\\{' made grep error out rather than return a false zero; caught and redone with -F.)",
      "reachability_verdict": {
        "clause_2_tripped": false,
        "measured_not_recalled": "Named-export propagation: 'grep -rn \"^export \\* \" packages/app-shell/src' returns ZERO matches anywhere in the package, so there is no export-star that could propagate the symbol without naming it — the barrel is all-named and a name-based check is therefore sound here. In the BUILT barrel, ViewColumnInspector appears 0 times in dist/index.d.ts and 0 times in dist/index.js (control symbol registerMetadataInspector: 1 and 1). Exports map: packages/app-shell/package.json declares only '.' and './styles.css' — no wildcard subpath. Real node resolution via createRequire: '@object-ui/app-shell/views/metadata-admin/inspectors/ViewColumnInspector.js', '@object-ui/app-shell/dist/views/...' and '@object-ui/app-shell/views/metadata-admin/index.js' all fail ERR_PACKAGE_PATH_NOT_EXPORTED, while '@object-ui/app-shell' resolves to dist/index.js.",
        "verdict": "The SYMBOL is not published: not importable by name, not reachable by subpath. But the BEHAVIOUR is published: inspectors/index.ts registers ViewInspector under 'view' via registerBuiltinInspectors(), getMetadataInspector IS a named export of the package root, and ViewInspector routes selection.kind === 'column' straight to ViewColumnInspector. So no consumer can import or override this component, yet every consumer of the package root gets its writeback behaviour in their designer. That asymmetry cuts toward the fork being a real decision rather than an internal refactor: the shape it writes reaches host applications, but no host can substitute a different one."
      },
      "writeback_fork": {
        "live": true,
        "how_measured": "Real component mounted, one identity edit driven through the real controls, the actual onPatch payload captured, and each resulting document run through ViewMetadataSchema (the same gate ResourceEditPage uses for a stored body: clientValidation.ts:579, mode 'edit').",
        "current_behaviour_option_3": [
          "{accessorKey:'name',header:'Name',width:120} + edit field key -> {accessorKey:'title',header:'Name',width:120} | parses BEFORE: false, AFTER: false",
          "{accessorKey:'name',header:'Name',width:120} + edit label -> {accessorKey:'name',header:'Title',width:120} | parses BEFORE: false, AFTER: false",
          "{accessorKey:'name',label:'Name'} + edit field key -> {accessorKey:'title',label:'Name'} | parses BEFORE: false, AFTER: false",
          "{field:'name',header:'Name'} + edit label -> {field:'name',header:'Title'} | parses BEFORE: false, AFTER: false",
          "CONTROL {field:'name',label:'Name',width:120} + edit field key -> {field:'title',label:'Name',width:120} | parses BEFORE: true, AFTER: true",
          "CONTROL bare string 'name' + edit field key -> 'title' | parses BEFORE: true, AFTER: true"
        ],
        "option_1_normalize_on_write": [
          "{accessorKey:'name',header:'Name',width:120} + edit field key -> {width:120,field:'title',label:'Name'} | parses: true",
          "{accessorKey:'name',header:'Name',width:120} + edit label -> {width:120,field:'name',label:'Title'} | parses: true",
          "{accessorKey:'name',label:'Name'} + edit field key -> {field:'title',label:'Name'} | parses: true",
          "{field:'name',header:'Name'} + edit label -> {field:'name',label:'Title'} | parses: true",
          "CONTROL {field:'name',label:'Name',width:120} + edit field key -> {width:120,field:'title',label:'Name'} | parses: true (byte-identical outcome to option 3 — canonical documents are untouched by the choice)"
        ],
        "what_changes_for_a_stored_document": "Only non-canonical documents move, and they move from unsaveable to saveable. Option 1 renames the key AND carries the value across (accessorKey's value becomes field's, header's becomes label's), so no author-visible content is lost. The cost is that it requires the editor to READ accessorKey as identity in order to migrate it — which is exactly the cross-vocabulary canonicalization core/src/utils/column-identity.ts:36-47 says the fold must never do. A drop-without-migrating variant avoids that objection but silently discards the author's header text, which is worse.",
        "the_option_the_issue_does_not_list": "Option 4 — retire the READ tolerance too (:64 'c.field ?? c.accessorKey', :68 'c.label ?? c.header'). Today the inspector displays 'name' as the column's field key for a document whose field key the spec says does not exist; that display is the same undeclared-alias read #5068 removed from the renderer, and it is what makes the closed loop invisible to the author. Under option 4 a legacy column shows an empty field key and the author re-authors it — no silent rewrite of stored bytes, no cross-vocabulary fold, at the cost of an editor that shows nothing for a column whose JSON visibly has a name. This is the only option consistent with BOTH in-repo authorities at once, which is why it should be on the ballot."
      },
      "open_questions": [
        {
          "question": "When the designer rewrites a stored view column that carries the refused {accessorKey,header} spelling, what should the saved document become? Note this is not 'should we tidy a legacy spelling' — measured, the document is ALREADY refused by the spec before and after any edit, so the real question is whether the editor is allowed to repair it in place, must leave it inert, or must stop pretending it can read it.",
          "options": [
            "A (issue option 1) Normalize on write: read identity canonical-first, write {field,label} only, drop accessorKey/header. Measured: every legacy/mixed shape becomes a parsing document on the first identity edit; canonical and string columns are byte-identical to today. Cost: requires reading accessorKey as identity to migrate its value, which core/src/utils/column-identity.ts:36-47 explicitly forbids ('the fold neither reads nor writes it'), and rewrites stored tenant bytes whose real-world population nobody has measured — the same 'no inventory' objection that made core choose mirror-not-delete at :160-172.",
            "B (issue option 2) A + a visible migration note in the inspector. Same data shape as A; adds user-facing copy, which needs all ten locale packs in @object-ui/i18n and widens the file surface beyond this card.",
            "C (issue option 3) Leave it. Measured cost: the closed loop stays — the designer shows the column, accepts the edit, and emits a document its own live validator refuses, with no route to a valid one. Note the client does NOT gate Save on this: ResourceEditPage's Save is disabled only by inspectorBlocking, which is fed exclusively by onBlockingIssuesChange, and ViewColumnInspector never sends it (it does not even accept the prop). The Zod issues are banner/inline display only. So the refused document is handed to the server and the server's verdict is authoritative — that last link is in objectstack, outside this repo, and is the one thing here I could not measure.",
            "D (not in the issue, surfaced by the measurement) Retire the READ tolerance at :64/:68 as well, so the inspector stops presenting a refused spelling as a valid identity. Pair with A for 'repair and stop reading', or with C for 'read only what the spec declares and let #5349's renderer diagnostic do the telling'. D is the only branch that satisfies both #5068/#3951 (no consumer-side tolerance alias) and core's boundary rule (never fold accessorKey into field) simultaneously."
          ],
          "recommendation": "A + D, i.e. normalize on write AND drop the read alias — but I am explicitly NOT taking it, because the axis that decides it is one I cannot measure from here. On 'real business need': the in-repo population is measured ZERO on the current tip (re-confirmed, every accessorKey belongs to the table/data-table component), so A's entire value rests on legacy tenant documents predating #4001, which is exactly what the issue flags as unmeasured and what core's mirror-not-delete precedent treats as disqualifying for a delete. If that population is zero, A is dead code and D alone is the whole fix, which also makes it the cheapest thing consistent with startup scope discipline. On 'hard to get wrong for AI-authored metadata': D dominates — the current read alias actively teaches the wrong shape by rendering a refused key as the column's identity in the authoring tool, and consumer-side tolerance in the authoring surface is precisely where AI-generated errors hide and spread. On 'long-term soundness': A resolves the declared-vs-read divergence at the producer as #3951 prescribes, but does so by crossing the metadata/adapter vocabulary boundary core deliberately drew, so if A is chosen the fold's docblock at column-identity.ts:36-47 needs amending in the same PR or the two rules contradict each other in the tree. The maintainer call I need: is there a known population of pre-#4001 stored view documents carrying accessorKey columns? Yes -> A+D. No -> D alone, and the write half is moot."
        }
      ],
      "out_of_scope_findings": []
    }
    

    Generated by Claude Code

  7. os-sales commented on Aug 22, 2026

    @os-sales
    Collaborator

    NEEDS DECISION — escalated to the maintainer (domain:ui seat PM, reviewer of record). No PR, no code change, clean tree. The dispatch told the dev to report this fork rather than pick it, and it did — but the measurement changed what the fork is, so this is not the card as filed.

    The card's own severity note is wrong, and that is the finding

    The issue says "nothing is broken today by this alone" and calls the two spellings a round-trip. Measured on the real component — mounted, one identity edit driven through the real controls, the actual onPatch payload captured and run through ViewMetadataSchema (the same gate ResourceEditPage uses, clientValidation.ts:579, mode edit):

    stored column after one inspector edit parses before parses after
    {accessorKey, header, width} {accessorKey:'title', header, width} false false
    {accessorKey, header, width} (label edit) {accessorKey, header:'Title', width} false false
    {accessorKey, label} {accessorKey:'title', label} false false
    {field, header} {field, header:'Title'} false false
    control {field, label, width} {field:'title', label, width} true true
    control bare string 'name' 'title' true true

    It is not a round-trip. It is a closed loop: the document is already refused before the edit and still refused after, because patchIdentity re-emits the spelling it found. No sequence of inspector edits can turn such a column into a saveable document. The two controls prove the instrument can produce true, and one of them caught a real bug in the harness on its first run.

    A fourth option the issue does not list, and it may be the whole fix

    The file also carries the READ half of the same tolerance alias — :64 c.field ?? c.accessorKey, :68 c.label ?? c.header. That is literally the construct #5068 retired from ObjectGrid, still present one layer up, and it is why the loop is invisible: the inspector displays 'name' as the column's field key for a document whose field key the spec says does not exist.

    Option D — retire the read tolerance too. A legacy column then shows an empty field key and the author re-authors it: no silent rewrite of stored bytes, no cross-vocabulary fold. It is the only branch consistent with both in-repo authorities at once, which is why it belongs on the ballot.

    Why the write half is not this seat's to decide: two authorities point opposite ways

    Option A (normalize on write) makes every legacy shape parse on the first identity edit and leaves canonical documents byte-identical — but it can only migrate accessorKey's value by reading it as identity, which is exactly what column-identity.ts forbids. If A is chosen, that docblock must be amended in the same PR or the tree carries two contradicting rules.

    Two measured facts the maintainer should have before ruling

    1. In-repo population of accessorKey-shaped view columns is ZERO on the current tip — re-confirmed with fixed-string search; every accessorKey hit belongs to the table / data-table component, which legitimately declares the key, and content/docs/plugins/plugin-grid.mdx:90 actively teaches "There is no accessorKey" for ListColumn. So option A's entire value rests on legacy tenant documents predating examples/schema-catalog: 13 个 grid 示例用了未声明的 cols 键,列数被静默丢弃 —— 要 3/4 列的示例在 docs 站实际渲染成 2 列 #4001 — the unmeasured population that made core choose mirror-not-delete.
    2. Save is not gated on this. ResourceEditPage's Save is disabled only by inspectorBlocking, fed exclusively by onBlockingIssuesChange — and ViewColumnInspector never sends it (it does not accept the prop). The Zod issues are banner/inline display only, so the refused document is handed to the server, and the server's verdict is authoritative. That last link lives in objectstack and is the one thing this seat could not measure.

    Reachability, measured rather than recalled — and the asymmetry matters

    grep -rn "^export \* " packages/app-shell/src returns zero, so there is no export-star in this package and a name-based barrel check is sound here (that is the check I warned could be meaningless; it was verified applicable before being relied on). ViewColumnInspector appears 0 times in dist/index.d.ts and dist/index.js — control symbol registerMetadataInspector appears 1 and 1. The exports map declares only . and ./styles.css, and real createRequire resolution of every deep path fails ERR_PACKAGE_PATH_NOT_EXPORTED.

    But the behaviour is published. inspectors/index.ts registers ViewInspector under 'view' via registerBuiltinInspectors(), getMetadataInspector is a named export of the package root, and ViewInspector routes selection.kind === 'column' straight to this component. So no consumer can import or override it, yet every consumer of the package root gets its writeback behaviour in their designer. Clause ② is not tripped (no published declaration moves), but that asymmetry is what makes this a product decision rather than an internal refactor.

    The ruling needed

    Is there a known population of pre-#4001 stored view documents carrying accessorKey columns?

    • Yes → A + D: normalize on write and drop the read alias, with column-identity.ts:36-47's docblock amended in the same PR.
    • No → D alone; the write half is moot, and A would be dead code.

    The dev's recommendation is A+D conditional on that population existing, and it explicitly declined to take it. I agree with the analysis and am not overriding it: the deciding axis is a fact about stored tenant data that neither of us can see from this repo.

    Option B (A plus a visible migration note) additionally needs user-facing copy in all ten @object-ui/i18n locale packs, which widens the file surface beyond this card.


    Generated by Claude Code

  8. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling recorded (2026-08-22, decision-inbox digest with the skills seat, session_01ApyDuQY2fkunMCqXiqvBhR)

    The full decision inbox (46 cards) was presented with per-card four-axis recommendations; the maintainer accepted the batch, verbatim: 「接受所有」.

    Ruled: Option D now — retire the READ tolerance at ViewColumnInspector.tsx:64/:68 (c.field ?? c.accessorKey, c.label ?? c.header), so the inspector stops presenting the refused {accessorKey, header} spelling as a valid column identity; a legacy column shows an empty field key and the author re-authors it. This is the only branch consistent with both in-repo authorities (#5068/#3951's no-consumer-side-tolerance and core/src/utils/column-identity.ts's never-fold-accessorKey boundary). Option A (normalize on write, dropping accessorKey/header in favor of {field, label}) is ruled conditionally: it lands only if a legacy stock of pre-#4001 stored view documents carrying accessorKey columns is proven to exist — the in-repo population is measured zero, so absent that proof the write half is moot and A stays out. If A is triggered, the column-identity.ts:36-47 docblock must be amended in the same PR.

    State: needs-user-decision removed → pm:queue (ready for dispatch).


    Generated by Claude Code

  9. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    Contributor

    Maintainer ruling — the conditional half is now closed

    Maintainer, 2026-08-22, quoted verbatim in answer to this card's outstanding factual question: 「无」.

    The question that was in front of the fork was: is there a known population of pre-#4001 stored view documents carrying accessorKey columns? The answer is no.

    Therefore: option D alone. Option A does not land.

    This does not re-adjudicate the 2026-08-22 02:25Z ruling — it completes it. That ruling had already settled D and left A conditional, in its own words: "it lands only if a legacy stock of pre-#4001 stored view documents carrying accessorKey columns is proven to exist — the in-repo population is measured zero, so absent that proof the write half is moot and A stays out." The proof was never produced and the maintainer has now stated there is none, so the condition fails and A is out for the stated reason rather than by omission.

    Consequences, so the implementing dev does not have to re-derive them:

    • In scope: retire the READ tolerance at ViewColumnInspector.tsx:64 (c.field ?? c.accessorKey) and :68 (c.label ?? c.header). A legacy {accessorKey, header} column then shows an empty field key and the author re-authors it.
    • ⛔ Out of scope: patchIdentity's writeback (:155/:157) is not touched. No stored document is rewritten.
    • ⛔ core/src/utils/column-identity.ts:36-47 is NOT amended. That obligation was attached to option A only ("if A is triggered, the docblock must be amended in the same PR"). A is not triggered, so the docblock stands as written — and D is in fact the branch that agrees with it, since D removes a cross-vocabulary read rather than adding one.
    • The closed loop this card measured is not repaired by D, and that is the ruled outcome, not an oversight: a legacy column stays unsaveable. What D removes is the invisibility — the inspector will stop presenting a spec-refused key as though it were a valid column identity. #5349's renderer diagnostic is the surface that does the telling.

    Stale-assignee repair, done in the same write. This card carried os-sales from a round-12 dispatch whose dev correctly returned needs_decision with no PR. That session (session_012u2pRjcqAYtoEjgr3wwhnK) has been dark since ~11:52Z and the card has been back in pm:queue since 02:25Z, so the assignment was a half-state that hid the card from every candidate query. Assignee cleared by the successor seat (session_01EuPCi56cnGyykygi3z9w4m, domain:ui), reason recorded here rather than left to be reconstructed.

    Not dispatched this round. Round 17 is at its batch ceiling of 3 (maintainer: 「并发保持3」), and this card's file lives under packages/app-shell/src/views/**, which #5619 holds this round. Queued as a round-18 candidate with the hot-file constraint recorded on the seat post (#5560 §3) rather than in this session's memory.


    Generated by Claude Code

  10. self-assigned this
    on Aug 23, 2026
  11. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 18
    Session: session_01EuPCi56cnGyykygi3z9w4m
    Branch: claude/issue-5344-viewcolumn-read-tolerance-retire
    Worktree: objectui-issue-5344
    Domain: domain:ui
    File surface: packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx and its tests (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus
    Clause-②: no
    Serial constraints cleared: packages/app-shell is free — #5619/PR #5718 merged 22:42Z and released src/views/**. Round-18 siblings fenced disjoint: #5424 (packages/auth) and #5627 (apps/console/src/components/FormPage.tsx).

    Tier basis. No path-derived mandate. Content judgement: clause ② does not trip — a prior measurement on this card established that ViewColumnInspector is not importable by name and not reachable by subpath (0 occurrences in the built barrel against a control symbol at 1; the exports map declares only . and ./styles.css), so no published declaration moves. Its behaviour is published via getMetadataInspector, which is what made the original fork a product decision — but that fork is now ruled, so what remains is implementation.

    RULING — settled, do not re-open (see the two ruling comments on this card): option D alone. Retire the READ tolerance so the inspector stops presenting a spec-refused spelling as a valid column identity. ⛔ patchIdentity's writeback is not touched — option A does not land, no stored document is rewritten. ⛔ core/src/utils/column-identity.ts:36-47's docblock is not amended (that obligation was attached to A only; D agrees with it). The closed loop is not repaired by D and that is the ruled outcome, not an oversight — what D removes is its invisibility.

    Premise re-verified on origin/main c7cd2b60b:

    • :65 — return c.field ?? c.accessorKey ?? '';
    • :69 — return c.label ?? c.header ?? colFieldKey(c);
    • :157 — the patchIdentity ternary, untouched by this card.

    ⚠️ Line numbers have drifted from the ruling's citation (it says :64/:68; measured :65/:69). Re-derive from the tree; ⛔ do not cite the ruling's numbers in the PR.

    ⚠️ A third site the ruling does not name — measure before you decide it is in or out. :49 declares const IDENTITY_KEYS = ['field', 'label', 'accessorKey', 'header'];, and it has two readers: :74 (Object.keys(c).some((k) => !IDENTITY_KEYS.includes(k)) — deciding whether a column carries non-identity keys) and :263 (hiddenFields={IDENTITY_KEYS}). Retiring the read alias at :65/:69 while IDENTITY_KEYS still classifies accessorKey/header as identity keys may leave the file internally inconsistent — or may be exactly right, since those two readers answer different questions than the identity read does. This is a PM mechanism assumption, not a ruling: measure what each reader does and state your conclusion. If honouring D at :65/:69 forces a judgement call about IDENTITY_KEYS that the ruling does not cover, ⛔ stop and report the fork rather than picking. The ruling's observable requirement is "the inspector stops presenting a refused spelling as a valid identity" — measure against that, not against the line numbers.

    Expect a legacy column to show an empty field key after this change. That is the ruled behaviour: the author re-authors it. #5349's renderer diagnostic is the surface that does the telling.


    Generated by Claude Code

  12. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    os-dev-report

    {
      "issue": 5344,
      "status": "done",
      "branch": "claude/issue-5344-viewcolumn-read-tolerance-retire",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5726",
      "premise_still_valid": true,
      "card_body_complete": "Verified NOT truncated — the body ends with the `## Reproduce` section carrying two `sed` commands (`sed -n '1,20p;60,70p;150,170p' …ViewColumnInspector.tsx` and `sed -n '60,70p' …previews/view-column-io.ts`). All 7 comments read in order: triage grading, round-12 claim, the prior dev's needs_decision report, the seat escalation, the 02:25Z ruling, the 「无」 comment closing its conditional, and the round-18 claim.",
      "premise_remeasurement": "All three PM line numbers reproduce EXACTLY on `origin/main` `c7cd2b60b`: `:65` `return c.field ?? c.accessorKey ?? '';`, `:69` `return c.label ?? c.header ?? colFieldKey(c);`, `:157` the `patchIdentity` ternary. The ruling's own `:64`/`:68` citation is indeed stale; the PR cites neither set of numbers, only the code. The prior dev's parse table also reproduces verbatim against `ViewMetadataSchema` — legacy false/false, canonical true/true, bare string true/true.",
      "summary": "Option D alone, as ruled. Retired both halves of the identity READ alias in `ViewColumnInspector` (`?? c.accessorKey`, `?? c.header`); `patchIdentity`'s writeback, `column-identity.ts`, and the closed loop are all untouched by design. Added a render-level suite that mounts the real component. Three files against the merge-base: the inspector, its new test, one changeset (`minor` — behaviour retirement; never `major` per AGENTS.md §版本号策略).",
      "identity_keys_conclusion": "LEFT ALONE, and this is a measurement rather than a preference — no fork to escalate. The PM's hypothesis was that retiring the read at `:65`/`:69` while `IDENTITY_KEYS` still lists the legacy spellings might leave the file inconsistent. It does not, because neither reader answers the identity question. (1) `hasDetailProps` asks whether collapsing an object back to a bare string would be lossy; its ONLY call site (`writeColumns`) is guarded by `wasString`, and `readColumn` maps a raw string to `{ field }`, so no string-origin column can ever carry `accessorKey`/`header` — the two entries are unreachable there. Removing them would nonetheless change what `writeColumns` serializes, which is the write path the ruling fences out. (2) `hiddenFields` asks which SPEC-DECLARED properties the curated layer already renders; measured, `SchemaForm` filters the JSONSchema's own property list, and `z.toJSONSchema(ListColumnSchema)` yields `[\"field\",\"label\",\"width\",\"align\",\"hidden\",\"sortable\",\"resizable\",\"wrap\",\"type\",\"pinned\",\"summary\",\"prefix\",\"link\",\"action\"]` — neither legacy key is declared, so those entries can never match anything. `hiddenFields` also never affects value preservation (its own docblock: \"still preserved on save\"; `setField` spreads unknown keys through). So the ruling's observable requirement is fully carried by the identity read, and touching `IDENTITY_KEYS` would breach the writeback fence for zero observable gain. Its docblock now records this so the next reader does not 'finish the job'.",
      "tests": "Union run on the FINAL commit `726fed914` (re-run after the last commit, not before it): `pnpm exec vitest run <18-file superset> --maxWorkers=2` → vitest's own lines `Test Files  18 passed (18)` / `Tests  114 passed (114)`, `VITEST_EXIT=0`; `pnpm --workspace-concurrency=2 --filter @object-ui/app-shell type-check` → `TYPECHECK_EXIT=0` with `grep -c 'error TS'` = 0, script echo confirms `tsc --noEmit && tsc -p tsconfig.test.json` actually ran (not a zero-match `--filter`); `pnpm --filter @object-ui/app-shell lint` → `LINT_EXIT=0`. All exit codes captured by redirect-then-`$?`, never across a pipe; the lock wrapper's own `VERDICT command-exit 0` line confirms each. FIRST ATTEMPT AT TYPE-CHECK WAS A FALSE RED I caused: 931 `error TS` (TS2307/TS6305/cascading TS7006) because the dependency closure was unbuilt — fixed with `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` (`BUILD_DEPS_EXIT=0`) and re-run green. SUPERSET IS DERIVED, NOT SAMPLED: `colFieldKey`/`colDisplayLabel` are module-private (grep: zero occurrences outside the file), `ViewColumnInspector` has exactly one importer (`ViewInspector`), and a scripted reverse-import closure over `packages/app-shell/src` returns 17 test files reaching it; +1 new = 18. Separately checked all 51 repo-wide test files importing the `@object-ui/app-shell` barrel for a column selection (`kind: 'column'` / `.columns[`) — zero hits, so nothing outside the package can observe the change except via a module-load or type error. REVERSE VERIFICATION, run from the COMMITTED state (fix committed first, source reverted with `git checkout origin/main -- <file>`, mutation confirmed on disk by anchored `grep -cF` = 1 for each removed alias, restored with `git checkout HEAD -- <file>` under a `trap … EXIT INT TERM`, restoration proven by an empty `git status --short`): the predicted direction was WRONG and I am reporting what happened. Predicted 1 red; got 3. Only one is the intended signal (`expected { widget: 'combobox', … } to deeply equal { widget: 'input', text: '' }`). The writeback pin went red for a widget-shape reason (`fireEvent.change` is inert on the Radix combobox the retired alias used to produce → 0 calls), and the residue pin went red as `Found multiple elements with the text: Name` (pre-change the panel title read `header` as the label too). Both non-signal rows now carry comments saying exactly that, so neither can be misread as evidence the writeback moved. No ablation of a package `dist/` was involved — the root vitest config aliases `@object-ui/*` to sibling `src/`, so the run consumes source, not build output.",
      "gates_run": [
        "pnpm exec vitest run <18-file derived superset> — `Test Files  18 passed (18)` / `Tests  114 passed (114)`",
        "pnpm --filter @object-ui/app-shell type-check — `TYPECHECK_EXIT=0`, 0 `error TS`, both tsc passes echoed",
        "pnpm --filter @object-ui/app-shell lint — `LINT_EXIT=0`",
        "check:control-bytes — `✅  check-control-bytes: OK (scanned 4778 tracked text file(s); skipped 85 binary).`",
        "check-changeset-presence — `✅  2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`",
        "changeset:check — `✅  No changeset declares a \\`major\\` bump.` + `✅  privatePackages declared`",
        "check:i18n-keys — `Every in-scope call-site key resolves against the en pack (2924 keys) …`",
        "check:i18n-drift — `No en value changed in this range.`",
        "check:i18n-dead-keys — exit 0 (its own footer states it is a REPORT, not a gate)",
        "check:spec-symbols — `✅  spec symbol derivation: 1295 files scanned against 4966 spec export names`",
        "check:phantom-deps — `✅  Every in-scope import is declared by the package that publishes it.`",
        "check:self-import — `✅  No package names itself inside its own src/.`",
        "check:esm-specifiers — exit 0 (`--specifiers-only`; its footer notes the load leg was not run)",
        "lint:coverage — `✅  lint coverage: 46/46 packages linted, 0 with outstanding errors`",
        "type-check:coverage — `✅  type-check coverage: 45/46 …` + `✅  test type-check coverage: 41/41`"
      ],
      "gates_not_run": "check:eager-closure — DECLARED, not skipped silently. It reads `apps/console/dist/eager-closure.json`, written only by a full console `vite build`, and without it exits 2 reporting a broken gauge rather than a verdict. My source diff adds ZERO module specifiers (verified: `git diff <merge-base> -- <source file> | grep '^+' | grep import` is empty; the only new imports live in a test file the console bundle never includes), so there is no eager-closure surface to move. CI runs it regardless. Repo-wide `pnpm lint` / `pnpm test` likewise left to CI, which runs the farm exactly once — the local narrowing above is the derived-superset one, declared here.",
      "files_changed": [
        "packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx (+43/-8)",
        "packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.identityRead.test.tsx (new, 175 lines)",
        ".changeset/viewcolumn-identity-canonical-read-5344.md (new, minor, @object-ui/app-shell)"
      ],
      "fence_compliance": "Nothing touched outside the granted surface. `patchIdentity` byte-identical (`:192`/`:193` verified post-edit). `packages/core/src/utils/column-identity.ts`, `packages/auth/**`, `apps/console/src/components/FormPage.tsx`, `packages/plugin-grid/**`, `packages/components/**`, `content/docs/**`, `apps/site/**` — all untouched. The `previews/view-column-io.ts` residue below qualified on defect class but NOT on the dispatch's hard fence, so it was filed rather than fixed in place.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5725: `previews/view-column-io.ts` (`colLabel` :29, `colFieldName` :39) still reads the retired `accessorKey`/`header` aliases, and its sole importer `FieldsListEditor` renders INSIDE this same inspector panel — so after this PR the panel's identity controls go empty for a legacy column while the column list one line above still reads `Name`. Searched open issues first (semantic search on view-column-io / colLabel / colFieldName / FieldsListEditor): no existing card. Two extras recorded there — the chain is `label ?? header ?? field ?? accessorKey`, so `header` is preferred OVER `field` (a canonical column carrying a stray `header` displays it instead of its own label), and `colFieldName` backs `usedFieldNames()`, so a spec-refused column still reserves a name in the Add-field picker. Labelled `finding` + `domain:ui`, unassigned, no `pm:queue`. Pinned in the new suite (`getAllByText('Name')` has length 1) with a comment naming the line to flip when it lands."
      ],
      "notes_for_pm": [
        "TWO-DOT TRAP HIT LIVE, caught: `git diff --stat origin/main..HEAD` reported 9 files including `packages/auth/**` and a deleted `.changeset/authguard-positions-5424.md` — that is round-18 sibling #5424 landing on `origin/main` (now `686d5d92c`) after my branch point, rendered as if I had reverted it. Re-derived from the merge-base `c7cd2b60b`: 3 files. The `check-changeset-presence` gate independently agrees (`2 source file(s) … 1 changeset`). The PR's own file count confirms it: 3 changed, +248/-8.",
        "No `skip-changeset` label action taken or needed — objectui has no such label mechanism (the dispatch's own note), and this PR ships a real changeset. Confirmed objectui has no `scripts/pm/`; gates were derived from `.github/workflows/` + root `package.json` as instructed, and `scripts/pm/dispatch-gates.mjs` was NOT used.",
        "Shared verify-lock used for every build/test via the objectstack entry point (objectui has none of its own). Real contention observed and waited out in-turn, never across turns: queued behind `objectui-issue-5424`'s turbo type-check (96s) and `objectui-issue-5627`'s console suite (154s); longest hold of my own was 157s.",
        "PR body read back after creation: intact, `Fixes #5344` is the first line, draft, session-URL footer survived. No other actor's state on the PR was altered."
      ]
    }

    Generated by Claude Code

  13. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    ACCEPT (on merits) — PR #5726 · reviewer of record, domain:ui seat, round 18

    ⚠️ On merits; landing held until gates are read by name. PR opened ~01:10Z and this lane's shards measured 8m55s–10m36s last round, so a read now would be premature. ⛔ Not flipped ready until 19 success + 3 skipped is verified.

    Fence clean: 3 files against the merge-base — the inspector, its new test, one changeset. patchIdentity byte-identical at :192/:193; column-identity.ts untouched; option A did not land. All three of my line citations reproduced exactly, and the PR cites neither my numbers nor the ruling's stale :64/:68 — only the code.

    The IDENTITY_KEYS question I raised is answered by measurement, and the answer is "leave it"

    I flagged it as a possible fork and attached a conditional stop. No fork exists, and the reason is specific rather than a judgement call — neither reader asks the identity question:

    • hasDetailProps asks whether collapsing an object back to a bare string would be lossy. Its only call site (writeColumns) is guarded by wasString, and readColumn maps a raw string to { field } — so no string-origin column can ever carry accessorKey/header, making those two entries unreachable there. Removing them would still change what writeColumns serializes, i.e. the write path the ruling explicitly fences out.
    • hiddenFields asks which spec-declared properties the curated layer already renders. Measured: z.toJSONSchema(ListColumnSchema) yields field, label, width, align, hidden, sortable, resizable, wrap, type, pinned, summary, prefix, link, action — neither legacy key is declared, so those entries can never match anything. It also never affects value preservation (setField spreads unknown keys through).

    So the ruling's observable requirement is carried entirely by the identity read, and touching IDENTITY_KEYS would breach the writeback fence for zero observable gain. The conclusion is now recorded in the docblock so the next reader does not "finish the job" — which is the right artefact for a trap that looks like an oversight.

    The reverse verification predicted 1 red and got 3 — and said so

    This is the part worth keeping. A wrong prediction is not a void leg, and this report treats it correctly: it names which single red is the intended signal (expected { widget: 'combobox', … } to deeply equal { widget: 'input', text: '' }) and diagnoses the other two rather than absorbing them:

    • the writeback pin went red for a widget-shape reason — fireEvent.change is inert on the Radix combobox the retired alias used to produce, so 0 calls;
    • the residue pin went red as Found multiple elements with the text: Name, because pre-change the panel title read header as the label too.

    Both non-signal rows now carry comments saying exactly that, so neither can later be misread as evidence the writeback moved. A 3-red run reported as "3 red, as predicted" would have been the failure mode here.

    Also correctly diagnosed rather than worked around: a first type-check run showing 931 error TS was the unbuilt dependency closure, fixed with --filter '@object-ui/app-shell^...' build and re-run green — this seat's recorded fresh-worktree gauge, met in the wild.

    ⚠️ A trap I want on the record: two-dot vs three-dot diff

    git diff --stat origin/main..HEAD reported 9 files, including packages/auth/** and a deleted .changeset/authguard-positions-5424.md — because round-18 sibling #5424 landed on origin/main after this branch point, rendering as though this PR had reverted a sibling's work. Re-derived from the merge-base: 3 files. On a lane landing several PRs an hour, a two-dot diff will routinely accuse a dev of reverting whatever merged while they worked. Three-dot (origin/main...HEAD) is the only correct form here, and check-changeset-presence plus the PR's own count independently agreed.

    Sanitizer rule confirmed to the letter: a backticked `<18-file superset>` survived as escaped entities while two bare <file> tokens inside JSON strings were eaten. That is the characterisation this lane recorded last round, now reproduced deliberately.

    #5725 — verified, and it changes what the panel looks like after this lands

    previews/view-column-io.ts (colLabel:29, colFieldName:39) still reads the retired aliases, and its sole importer FieldsListEditor renders inside this same inspector panel. So after this PR a legacy column shows empty identity controls while the column list one line above still reads Name — a visible inconsistency within one panel.

    I am landing anyway, and the reasoning should be legible: the prior state was fully misleading (a spec-refused column presented as having a valid identity everywhere); this state is half misleading and the remaining half is now a filed, measured card rather than an unknown. Holding a correct, ruled, in-fence fix until an out-of-fence sibling is ready is how a backlog stalls. #5725 is a priority round-19 candidate and its two extras are worth reading first: the chain there is label ?? header ?? field ?? accessorKey, so header is preferred over field; and colFieldName backs usedFieldNames(), so a spec-refused column still reserves a name in the Add-field picker. The new suite already pins the line to flip when it lands.

    Landing blocked only on the by-name gate read.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions