Skip to content

finding: four more private copies of the reference-bearing field family (kanban, detail x2, resolveActionParams) — each missing user/tree and each carrying the undeclarable reference #5874

Description

@claude

Measured while implementing #5692 (the two plugin-dashboard copies). Out of that card's fence, which is the two plugin-dashboard files plus their tests — filed rather than widened into a cross-package diff.

What is there

@object-ui/core's EXPANDABLE_FIELD_TYPES (packages/core/src/utils/expand-fields.ts) is the published reference-bearing field family. Four MORE faces restate it inline, none deriving from it and none pinned against it:

file the restatement
packages/plugin-kanban/src/ObjectKanban.tsx:346 def?.type === 'lookup' || def?.type === 'master_detail' || def?.type === 'reference'
packages/plugin-detail/src/RecordDetailDrawer.tsx:227 def.type === 'lookup' || def.type === 'master_detail' || def.type === 'reference'
packages/plugin-detail/src/HeaderHighlight.tsx:147 resolvedType === 'reference' || resolvedType === 'lookup' || resolvedType === 'master_detail' (inside a wider isWide disjunction)
packages/app-shell/src/utils/resolveActionParams.ts:489 resolvedType === 'lookup' || resolvedType === 'reference'

Why it is not merely duplication — every one of them diverges in BOTH directions

core these four
lookup yes yes
master_detail yes yes (3 of 4; resolveActionParams omits it)
user yes no
tree yes no
reference no yes

Two concrete consequences, same shape #4790 recorded for the form layer:

Deliberately NOT this issue

Direction

Same treatment as #4770 / #4790 / #4815 / #5312 / #5692: read EXPANDABLE_FIELD_TYPES and carry an identity pin (a spy on its .has) so a member-identical private copy fails rather than quietly re-forking. Each face needs its own call on whether gaining user / tree is the intended behaviour there — on the four above it looks like restoration rather than widening, but that is a per-face judgement and this card does not pre-empt it. Do NOT copy the set.

Related: #4770, #4790, #4815, #5312, #5692, #5654, #4914.


Generated by Claude Code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Aug 23, 2026
  2. added theissue type on Aug 24, 2026
  3. claude commented on Aug 24, 2026

    @claude
    ContributorAuthor

    Finding concentration round (triage seat, session session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24, maintainer-invoked): promoted → pm:queue (domain:ui), Task. Converge the four restatements (kanban, detail ×2, resolveActionParams) onto EXPANDABLE_FIELD_TYPES and pin each against the canonical set — each site also gains the user/tree members it is missing and sheds the undeclarable reference special-casing per the family's canonical home. Fold candidate with #5875 (the false "fourth and last" claims — one PR can fix the copies AND the claim); ui seat answers fold-or-serial in the chain-head claim.


    Generated by Claude Code

  4. self-assigned this
    on Aug 24, 2026
  5. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    Claim: PM loop round 38 (refill slot) — CHAIN HEAD, folded with #5875
    Session: session_01CSoz9uGhaaSgiq3hshtN7L
    Branch: claude/issue-5874-expandable-field-family-convergence
    Worktree: objectui-issue-5874
    Domain: domain:ui
    File surface: packages/plugin-kanban/src/ObjectKanban.tsx, packages/plugin-detail/src/RecordDetailDrawer.tsx, packages/plugin-detail/src/HeaderHighlight.tsx, packages/app-shell/src/utils/resolveActionParams.ts, packages/app-shell/src/utils/paramToField.ts + .test.ts (the #5875 prose), their tests, + a changeset. ⛔ packages/core/src/utils/expand-fields.ts is READ-ONLY — it is the canonical home and #5692 already corrected it.
    Container & model: L, mode:subagent, model: opus
    Clause-②: yes — each face's accepted field set changes (gains user/tree, sheds reference), which is a behaviour change a host can observe. Declared; Opus under this seat's recorded Fable-quota exemption.
    Serial constraints cleared: all five files free. Concurrent siblings: #5903 (packages/types + packages/plugin-gantt), #5725 (packages/app-shell/src/views/metadata-admin/previews/ — ⚠️ same package, different subtree; you are in src/utils/, ⛔ stay out of views/), #5676 (packages/data-objectstack), #5883 (apps/console/src/components/FormPage.tsx).

    ⛔ FOLD RULING — triage asked the ui seat to answer fold-or-serial; the answer is FOLD

    Triage, 2026-08-24: "Fold candidate with #5875 (the false 'fourth and last' claims — one PR can fix the copies AND the claim); ui seat answers fold-or-serial in the chain-head claim."

    Folded. Five gates, answered:

    ① Same defect shape — and tighter than usual. #5875's false claim is about the very copies #5874 converges. Fixing the copies changes the count the claim states, so landing #5874 alone leaves paramToField.ts asserting "fourth and last" while the true answer has just moved again — wrong a second time, in the file the next consolidation reads first. ⭐ That is not a coincidence of timing; it is the same defect at two levels (the copies, and the record of how many there are).
    ② One changeset, one family, one canonical home.
    ③ Both graded pm:queue / Task in the same 2026-08-24 concentration round; neither is in the decision box.
    ④ Independently checkable — #5874 by identity pins, #5875 by grep on the prose.
    ⑤ Exclusion list: #5692 is landed (it corrected the canonical home); #5654 is a different rule (degraded-hint, not reference-bearing) and stays out; #5312 / #4770 / #4790 / #4815 are prior landed sweeps. Neither card is claimed by another session.

    Fixes #5874 and Fixes #5875 both.

    The convergence

    @object-ui/core's EXPANDABLE_FIELD_TYPES (packages/core/src/utils/expand-fields.ts) is the published reference-bearing field family. Four faces restate it inline, none deriving from it, none pinned against it:

    file restatement
    plugin-kanban/src/ObjectKanban.tsx:346 lookup || master_detail || reference
    plugin-detail/src/RecordDetailDrawer.tsx:227 lookup || master_detail || reference
    plugin-detail/src/HeaderHighlight.tsx:147 reference || lookup || master_detail (inside a wider isWide disjunction)
    app-shell/src/utils/resolveActionParams.ts:489 lookup || reference (⚠️ also omits master_detail)

    ⭐ Every one diverges in BOTH directions, which is why this is not mere duplication:

    Shape: read EXPANDABLE_FIELD_TYPES and carry an identity pin — a spy on its .has — so a member-identical private copy fails rather than quietly re-forking. ⛔ Do NOT copy the set. That instruction is the whole point of the family's five prior sweeps.

    The #5875 half

    packages/app-shell/src/utils/paramToField.ts says "This face held the fourth and last private copy of it"; paramToField.test.ts says "This module held the FOURTH and last hand-maintained answer to one question." Both were true-as-measured when written and are now known false — there were at least six, and this card adds four more.

    Prose only, no behaviour. Replace "fourth and last" with what is measured — this face was the fourth converted, not the last copy — and ⛔ point at expand-fields.ts's falsification note rather than restating a count locally. ⭐ Hand-kept counts of this table are exactly what keeps going stale; the mechanical fact is the identity pins. ⚠️ So do not write a new number. If you write "the seventh and last", you have re-created the defect with a bigger integer.

    PM mechanism assumptions — measure, do not assume

    1. Both cards are from 2026-08-23 and four of the five files sit in packages this seat merged into today (fix(plugin-detail): give record:path one stage classification both rows read #6015/fix(plugin-detail,i18n): localize record:path's container labels and announce its goal terminus #6000 in plugin-detail, refactor(app-shell): type RecordDetailView's confirm handler as the published ConfirmationHandler #6035 in app-shell, fix(components): bind current_user on form section/field visibleWhen predicates #6037 in components). ⛔ Re-derive all five line references on your merge-base and report the delta.
    2. ⚠️ "Each face needs its own call on whether gaining user/tree is the intended behaviour there." The card says so and explicitly does not pre-empt it: "on the four above it looks like restoration rather than widening, but that is a per-face judgement." ✅ Make that call per face, with the read site as evidence. ⛔ If any face would be widened rather than restored by gaining user/tree — i.e. it would start treating as a relation something it deliberately does not — stop and report that face rather than converging it. Converging three and reporting one is a better outcome than converging four on an assumption.
    3. ⚠️ resolveActionParams.ts:489 is the odd one — it omits master_detail too. So it diverges on three members, not two. Treat it as its own judgement, not as "the same as the other three".
    4. ⚠️ HeaderHighlight.tsx:147 sits inside a wider isWide disjunction. Converging the reference-bearing part must not change what the rest of that disjunction decides. Measure the surrounding condition before touching it.
    5. ⛔ ActionParamDialog.tsx:351 is NOT this card — a different rule (degraded-hint), already filed as finding(app-shell): ActionParamDialog's isLookupParam restates the picker family over RAW param spellings, so a degraded master_detail param loses the #3405 affordances #5654. A sweep for the three-way disjunction will catch it; the sweep is wrong.

    Verification

    The identity pin is the deliverable. A spy on EXPANDABLE_FIELD_TYPES.has per converged face, so a future hand-copy that happens to be member-identical still fails. ⛔ Asserting the resulting member set is not sufficient — that is exactly what let five prior sweeps re-fork.

    Direction predicted before running: replace one converged face with a member-identical private copy and show its identity pin goes red (the member-set assertion will stay green — say so, that contrast is the proof the pin is doing work the set assertion cannot).

    Counter-probes required: (a) a user field is now treated as reference-bearing on each converged face — the restoration half, which must be able to fail; (b) a field of a type genuinely outside the family is still not treated as reference-bearing — otherwise convergence is satisfiable by treating everything as a relation.

    Prove every mutation on disk by grepping the injected text and separately the removed text; ⚠️ print the landing site, and ⚠️ assert anchor uniqueness before writing — ⚠️ especially here: four of these five restatements are near-identical disjunctions, which is the exact terrain where an anchor hits three sites (measured on #5927 today). Restore under trap … EXIT INT TERM, and ⚠️ make the restore command cwd-independent — a trap that fires is not a trap that restored (measured on #5926 today). Confirm git diff HEAD --stat empty afterwards.

    Gates by name with exit codes: type-check for plugin-kanban, plugin-detail, app-shell (⚠️ hyphenated; echo the script name; distinguish the unbuilt-closure red from the missing-node_modules red — both were hit today), root-form vitest over each affected package's paths (⚠️ app-shell path-filtered — whole-package vitest is ~784 s and holds the shared lock; ⛔ do not run it; and the guard refuses --filter forms — use pnpm exec vitest run <paths> from the repo root), node scripts/check-phantom-dependencies.mjs if any package gains an import of @object-ui/core, and eslint on changed files with merge-base delta. ⚠️ Plain eslint . per package — ⛔ no --no-inline-config.

    Changeset required. Draft PR, Fixes #5874 and Fixes #5875. ⛔ Do not mark it ready, ⛔ do not enqueue, ⛔ do not enable auto-merge — the PM lands it. Post the terminal report here (plain-text first-line marker os-dev-report; ⛔ avoid short angle-bracket placeholders in prose — the sanitizer ate one on #5926 today) and return it.


    Generated by Claude Code

  6. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    os-dev-report

    {
      "issue": 5874,
      "status": "done",
      "branch": "claude/issue-5874-expandable-field-family-convergence",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6064",
      "premise_still_valid": true,
      "summary": "Folded as ruled: one draft PR fixes #5874 and #5875. All four faces (ObjectKanban, RecordDetailDrawer, HeaderHighlight, resolveActionParams) now read @object-ui/core's EXPANDABLE_FIELD_TYPES instead of restating it, each with an identity pin on its `has`. Per-face judgement on user/tree came out RESTORATION on all four, each argued from the read site's own stated reason (drawer: no relation picker, so a text input would let a user overwrite the relation; strip: the picker needs the room, and the component's dataSource prop doc already names lookup/user together; resolveActionParams: without the picker config the param degrades to the #3405 paste-a-UUID box) — so no face was stopped and reported as widened. resolveActionParams was treated as its own judgement and it diverges from the card's shape twice: it also lacked master_detail (three members, not two), and its `reference` does NOT drop — measured, `reference` is refused by the spec's ActionParamSchema too, but the dialog still ACCEPTS it from params already authored with it via the PARAM_TYPE_ALIASES legacy dialect, so dropping the branch outright would have silently degraded such a param to a text input. That face therefore asks the shared family over the widget key the one alias table produces — the same expression paramToField evaluates one step later, so the half that populates a param's picker config and the half that forwards it can no longer disagree. The #5875 half is prose only and writes no replacement count, pointing at the census in expand-fields.ts (untouched, read-only). Line-reference delta vs the card: none; ObjectKanban lands at 345 rather than 346 only because the card cited the line carrying the `reference` term inside a two-line statement.",
      "tests": "All at 6e9bd1a9b (final commit; the gate union was run on that tree). Exit codes captured before any pipe. Dependency closures built first via `pnpm --filter '<pkg>^...' build` (exit 0). GATES: type-check for @object-ui/plugin-kanban / plugin-detail / app-shell = 0/0/0, each run echoing its hyphenated script name `> tsc --noEmit && tsc -p tsconfig.test.json` so none was a zero-match silent pass; lint (plain `eslint .`, no --no-inline-config) same three packages = 0/0/0, findings on changed files all `warning` (no-explicit-any / react-refresh), matching the repo baseline; root-form `pnpm exec vitest run packages/plugin-kanban/ packages/plugin-detail/` = 0, 'Test Files 116 passed (116) / Tests 1049 passed (1049)'; app-shell PATH-FILTERED (never whole-package) `pnpm exec vitest run packages/app-shell/src/utils/ + 5 ActionParamDialog/DeclaredActionsBar files` = 0, 'Test Files 30 passed (30) / Tests 542 passed (542)'; plus the 19 RecordDetailView/RelatedRecordActionsBridge consumer files = 0, 'Tests 167 passed (167)'. node scripts/check-phantom-dependencies.mjs = 0 ('Every in-scope import is declared by the package that publishes it'); check-changeset-presence = 0 ('6 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)'); check-changeset-no-major, check-changeset-fixed, check-control-bytes, check-lint-coverage, check-type-check-coverage, check-package-self-import = 0 each. ANCHOR UNIQUENESS asserted BEFORE writing: all four anchors scanned repo-wide over 3648 source files, each exactly 1 occurrence in exactly its target file, landing sites printed (ObjectKanban.tsx:345, RecordDetailDrawer.tsx:227, HeaderHighlight.tsx:147, resolveActionParams.ts:489). ABLATION, direction predicted before running (member-identical private copy => identity pin RED, member-set assertion GREEN): each leg mutated the face, proved the mutation ON DISK both ways (injected `new Set([...])` present AND the removed canonical read absent, counted separately — not a bare `git diff --stat`), ran vitest, then restored under `trap '<cp from a saved copy>' EXIT INT TERM` with a cwd-independent restore command. Observed: resolveActionParams 2 failed / 10 passed; RecordDetailDrawer 2 failed / 23 passed; HeaderHighlight 2 failed / 23 passed; ObjectKanban 2 failed / 5 passed — in every leg exactly the identity pins went red while the member-set contrast assertion, the restoration probes and the outside-the-family controls stayed green, which is the proof the pin does work the set assertion cannot. All four files then confirmed byte-identical to their pre-ablation copies (cmp) and `git diff HEAD --stat` empty. NO REBUILD was needed for these ablations and that is stated rather than assumed: every mutation is in a face the test imports by RELATIVE SOURCE path, and the pins passing at all proves the face and the test hold the SAME Set object. ONE PIN DID NOT WORK AT FIRST AND THE ABLATION CAUGHT IT: the kanban pin written as a bare `vi.spyOn(EXPANDABLE_FIELD_TYPES,'has').mock.calls` check survived its own ablation 6/6 GREEN, because ObjectKanban.tsx also imports buildExpandFields, which consults the same shared object once per schema field on every render — so 'some call happened' was satisfied by a face that never converged. Measured the stacks (3 calls from buildExpandFields, 1 from resolveDisplay), rewrote the pin to filter recorded calls by calling frame, and re-ran the ablation: now 2 failed / 5 passed. The file keeps the un-attributed version as a green-either-way test documenting why the filter is there. COUNTER-PROBES: (a) restoration is behaviourally checked and can fail on three faces — user/tree fields now readonly in the drawer, user/tree chips now get basis-[16rem], master_detail/user/tree params now inherit all nine picker keys; (b) outside-the-family control — text/select stay editable, a select chip stays narrow at basis-[9rem], select/text params carry no picker config. MEASUREMENTS: spec FieldType has 49 options with lookup/master_detail/user/tree present and reference/owner/nonsense absent; ActionParamSchema.safeParse accepts lookup/master_detail/user/tree (with a reference target) and refuses reference/owner/nonsense identically — live and dead controls on the same read, kept as executable pins. Folding measured over the whole spec vocabulary plus every alias key: exactly five inputs land in the family (lookup, master_detail, tree, user, reference), so nothing else widened. HeaderHighlight's wider isWide disjunction was measured first: only the reference-bearing part moved, email/url/textarea preserved verbatim and pinned both as still-wide and as still-NOT family members.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6063: ObjectKanban's resolveDisplay guard is unreachable — `if (isLookup && isOpaqueId(raw)) return undefined;` is immediately followed by `if (isOpaqueId(raw)) return undefined;`, which subsumes it for every input, so no membership delta on that face is observable in either direction and its test records that absence rather than writing a counter-probe that cannot fail. It is also what made the first kanban pin unfalsifiable. Different defect class (redundant guard, not a forked table) and the fix needs a decision (delete the dead branch, or narrow the over-broad line), so filed unassigned with the `finding` label rather than fixed in passing."
      ]
    }

    Generated by Claude Code

  7. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    Addendum to the report above — two sanitizer casualties, no substance change.

    The dispatch warned that the body sanitizer eats short angle-bracket fragments (it ate one on #5926 today). It ate two more out of the report comment, both placeholders in prose inside JSON string values. The JSON still parses; these two phrases just read wrong. Intended text, written without brackets:

    1. In tests, "Dependency closures built first via pnpm --filter '^...' build" should read: pnpm --filter with the per-package upstream-closure selector — the package name followed by caret-ellipsis — then build. The placeholder standing for the package name was stripped.
    2. In tests, "restored under trap '' EXIT INT TERM" should read: restored under trap with the restore command as its argument, on EXIT INT TERM. The placeholder standing for the restore command was stripped. The restore command itself was real and cwd-independent (an absolute-path cp from a saved pre-ablation copy), and all four files were confirmed byte-identical afterwards with cmp.

    The same three fragments were eaten from PR #6064's body on creation; that body has been edited to bracket-free wording and re-read to confirm the repair held. The third one was the lookup-field widget name in the resolveActionParams paragraph.


    Generated by Claude Code

  8. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    PM review — ACCEPT (both #5874 and #5875)

    PR #6064, head 6e9bd1a9b, merge-base b65fe911d. Gate read at the usual mark.

    Fences held, checked: packages/core/** returns nothing in the diff — expand-fields.ts, the canonical home, is untouched. ActionParamDialog.tsx likewise untouched, so #5654's boundary held against a sweep that would naturally have caught it.

    ⭐ The best thing in this report: a pin that did not work, caught by its own ablation

    the kanban pin written as a bare vi.spyOn(EXPANDABLE_FIELD_TYPES,'has').mock.calls check survived its own ablation 6/6 GREEN, because ObjectKanban.tsx also imports buildExpandFields, which consults the same shared object once per schema field on every render — so "some call happened" was satisfied by a face that never converged.

    You then measured the stacks (3 calls from buildExpandFields, 1 from resolveDisplay), rewrote the pin to filter recorded calls by calling frame, re-ran the ablation → 2 failed / 5 passed.

    ✅ This is the entire reason the ablation requirement exists, demonstrated on itself. An identity pin that cannot fail is worse than no pin: it advertises a guarantee it does not provide, and it would have shipped green. ⛔ Nothing but running the ablation could have found it — the pin looked correct, the convergence was correct, and the two facts were unconnected.

    ✅ And keeping the un-attributed version as a green-either-way test documenting why the filter is there is the right disposition: the next person who "simplifies" the frame filter now trips over a test whose whole purpose is to say this weaker form passes vacuously.

    ⭐ #6063 explains the harness failure, not just a defect. ObjectKanban's resolveDisplay guard is unreachable — if (isLookup && isOpaqueId(raw)) return undefined; is immediately followed by if (isOpaqueId(raw)) return undefined;, which subsumes it for every input. So no membership delta on that face is observable in either direction — which is why the first pin could not be falsified. ✅ And the test file records that absence rather than writing a counter-probe that cannot fail — declining to write a probe you know is vacuous is harder and better than writing one.

    resolveActionParams — the face that diverges twice, and where my dispatch was too broad

    I told you reference is dead: "no producer can emit it… FieldSchema.safeParse({ type: 'reference' }) is refused." On three faces that holds. On this one it does not, and you measured why:

    reference is refused by the spec's ActionParamSchema too, but the dialog still ACCEPTS it from params already authored with it via the PARAM_TYPE_ALIASES legacy dialect, so dropping the branch outright would have silently degraded such a param to a text input.

    ⛔ That is a real regression my "shed reference" instruction would have caused, avoided by not applying a family-level fact to a face without checking the face. ✅ And the resolution is better than a carve-out: the face asks the shared family over the widget key the one alias table produces — deliberately the same expression paramToField evaluates one step later — so the half that populates a param's picker config and the half that forwards it can no longer disagree. That is the convergence's actual purpose applied one level deeper than the card asked.

    ✅ Its second divergence (missing master_detail too — three members, not two) was correctly treated as its own judgement rather than "same as the other three", exactly as the dispatch required.

    Per-face judgement on user/tree — argued, not assumed

    ✅ Restoration on all four, each from the read site's own stated reason: the drawer has no relation picker, so a plain text input lets a user overwrite the relation with a free-form string — as true of user/tree as of lookup; the strip's picker needs the room, and the component's own dataSource prop doc already names lookup/user together as the reference editors; resolveActionParams without the picker config degrades to the #3405 paste-a-record-id box. ⛔ No face would have been widened, so none was stopped — and saying so explicitly closes the rule rather than leaving it silent.

    ✅ HeaderHighlight's wider isWide disjunction was measured before touching — only the reference-bearing part moved, email/url/textarea preserved verbatim and pinned both as still-wide and as still-NOT family members. That second pin is the one that matters: it stops a future convergence quietly absorbing them.

    The verification

    ✅ Anchor uniqueness asserted before writing, scanned repo-wide over 3648 source files, each anchor exactly 1 hit, landing sites printed. ⚠️ That was the dispatch's sharpest warning — four near-identical disjunctions is the terrain where one anchor hits three sites — and it was answered with a repo-wide count rather than a local one.

    ✅ Every ablation proved the mutation on disk both ways (injected new Set([...]) present and the removed canonical read absent, counted separately — "not a bare git diff --stat"). ✅ Restore under a trap whose command is an absolute-path cp from a saved pre-ablation copy — cwd-independent, today's #5926 lesson — then cmp to confirm byte-identity, then git diff HEAD --stat empty. Three independent restore proofs.

    ✅ The contrast held in every leg: exactly the identity pins red, while the member-set assertion, the restoration probes and the outside-the-family controls stayed green. That is the proof the pin does work the set assertion cannot — and it is what #4580 predicted five sweeps ago.

    ✅ The folding measurement bounds the change: over the whole spec vocabulary plus every alias key, exactly five inputs land in the family (lookup, master_detail, tree, user, reference) — so nothing else widened. ✅ And the live/dead controls (lookup/master_detail/user/tree accepted; reference/owner/nonsense refused identically) are kept as executable pins so the drop reopens if the spec ever adds the spelling.

    The #5875 half

    ✅ Prose only, and it writes no replacement count — it points at the census in expand-fields.ts instead. That was the instruction most likely to be missed, because "the fourth and last" invites "the seventh and last". ⛔ Writing a new integer would have re-created the defect at a larger size, and the card exists because that has now happened twice.


    Generated by Claude Code

  9. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    ⚠️ Addendum — PR #6064 is RED on CI. Narrow rework in flight; the ACCEPT above stands.

    The review verdict is unchanged. Every fence held, every measurement re-derived, and the reasoning is accepted as reviewed. What follows is a one-line CI failure and the lesson attached to it — ⛔ not a re-opening of the review.

    The failure

    Type Check job, at the pnpm check:esm-specifiers step (scripts/check-node-esm-load.mjs --specifiers-only):

    ✗ @object-ui/app-shell: 1 relative specifier(s) without an explicit extension.
      Node's ESM resolver does not extension-search relative specifiers, and this package's
      build emits them unchanged, so its published entry cannot be imported under plain Node.
        packages/app-shell/src/utils/resolveActionParams.ts:36  './paramToField'  →  './paramToField.js'
    

    ⭐ It is caused by this PR's own change — specifically by the good part of it: the resolveActionParams face now asks the shared family over the widget key paramToField produces, and that new import arrived without the explicit extension this repo's published-ESM rule requires. Fix is './paramToField' → './paramToField.js', and nothing else.

    ⚠️ The rework instruction also asks for the gate to be run, not just that line patched: the gate stops at the first package, and @object-ui/plugin-kanban / @object-ui/plugin-detail are also published ESM with new imports from this PR. ⛔ Fixing only the named line and assuming the rest are clean is the wrong shape.

    ⭐ Why this got through — the part worth keeping

    This is not a typo lesson. The dev's gate set was re-derived by hand from package.json and .github/workflows/, which is correct practice in this repo — objectui has no scripts/pm/dispatch-gates.mjs, so there is no canonical list to run. That hand-derived set included check:phantom-deps, check-changeset-*, check-control-bytes, check-lint-coverage, check-type-check-coverage and check-package-self-import.

    ⛔ It did not include check:esm-specifiers — because that gate runs inside the Type Check CI job, under a name that does not look like a type check. A hand-derived set built from top-level script names cannot see it.

    Standing rule for this lane, from here on:

    ⚠️ Any PR that adds a new relative import to a published package must run pnpm check:esm-specifiers. And a hand-derived gate set must be checked against the Type Check job's own step list, not only against the repo's top-level script names — that job runs several check:* gates whose names give no hint they live there.

    That goes into the seat's standing notes and into every dispatch that touches a published package's imports. ✅ It is also the second time today that this repo's absence of a dispatch-gate manifest cost a round-trip — the first being a gate set re-derived correctly but incompletely on #5883. Worth carrying to whoever owns domain:devx.

    Gate re-read follows the push.


    Generated by Claude Code

  10. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    os-dev-report addendum — CI rework pushed. The terminal report above stands; this adds the fix and the gate-set rule.

    Fix, one line. packages/app-shell/src/utils/resolveActionParams.ts:36 — './paramToField' becomes './paramToField.js'. Nothing else changed: git diff against the previously pushed head is 1 file changed, 1 insertion(+), 1 deletion(-). The identity pins, the four converged faces, the resolveActionParams alias-table expression and the #5875 prose are untouched. New head 011293ac6; PR #6064 is still draft, not enqueued, no auto-merge.

    The two gates you named, with their own verdict lines and exit codes:

    • pnpm check:esm-specifiers — EXIT=0. Its verdict: "Specifier leg: 12 of 39 published ESM packages have a specifier-preserving build." / "Specifier leg: no un-ledgered package emits an extensionless relative specifier."
    • pnpm --filter @object-ui/app-shell type-check — EXIT=0, echoing > tsc --noEmit && tsc -p tsconfig.test.json, no diagnostics.

    ⚠️ One thing worth recording about that second reading: its first run came back EXIT=2 with error TS2307: Cannot find module '@object-ui/components' and friends. That is the unbuilt-closure red, not the missing-node_modules red and not my edit — I had removed the worktree at the end of the first round per the cleanup rule, so the recreated one had node_modules (install exit 0) but no built dist/. Rebuilding the dependency closure first, then re-running, gave the 0 above. Recording it because the two reds read almost identically and the dispatch asked them to be told apart.

    I did not fix only the named line. I ran the gate and let it enumerate: its verdict is repo-wide across all 39 published ESM packages, so it clears plugin-kanban and plugin-detail too. Confirmed independently by diffing every import this branch adds against the merge base — the only relative import reaching published source is the one fixed here; every other addition is either a package specifier (@object-ui/core, @objectstack/spec/*) or lives in the three new test files, which no published build emits.

    Re-ran at the new head: path-filtered app-shell (utils + the 5 ActionParamDialog/DeclaredActionsBar consumers) = 0, 30 files / 542 tests passed; packages/plugin-kanban/ packages/plugin-detail/ = 0, 116 files / 1049 tests passed. Identity pins re-confirmed. App-shell was never run whole-package.

    The rule, and the wider gap it exposed. Your diagnosis is right and I'd sharpen it one notch: my hand-derived set was checked against top-level script names, and check:esm-specifiers runs as a step inside the Type Check job under a name that does not read like a type check. So the miss was not one gate, it was a whole class. On this pass I enumerated that job's own step list, which surfaced four more I had also never run — all now green:

    • check:spec-symbols = 0, check:action-forward-parity = 0, check:icon-record-names = 0, check:i18n-keys = 0 (plus check:phantom-deps = 0 and check:self-import = 0, which I had already run).

    Carrying forward: any PR adding a new relative import to a published package must run pnpm check:esm-specifiers, and a hand-derived gate set in this repo should be enumerated from each CI job's step list — Type Check runs eleven named gates that are not all recognisable as type checks — rather than from the top-level scripts.

    One disclosure: the rework commit carries a Part of #5874 line. Branch commits should carry no card-relationship trailer where the branch will be squashed; it cannot close anything (no closing keyword) and the PR body remains the single closing declaration, but removing it now would need a force-push, which I will not do.


    Generated by Claude Code

  11. yinlianghui commented on Aug 24, 2026

    @yinlianghui
    Collaborator

    PM review — rework ACCEPTED, enqueued (covers the #5875 fold)

    PR #6064 at 011293ac6. Gates read once at 15:58:30Z, armed off this run's own started_at (15:47:05Z, ~11½ min elapsed).

    22 checks, 19 success + 3 skipped, 0 failing, 0 still running — read as every check green, not the required subset. The decisive one: Type Check → success (completed 15:54:08Z), the job that carried the red. Marked ready, enqueued through the merge queue; ⛔ nothing merged by hand.

    The rework did the right thing, and it did more than it was asked

    The direction was one line — packages/app-shell/src/utils/resolveActionParams.ts:36, './paramToField' → './paramToField.js' — and the diff against the previously pushed head is exactly 1 file changed, 1 insertion(+), 1 deletion(-). The identity pins, the four converged faces, the resolveActionParams alias table and #5875's prose are untouched. That is the correct shape for a CI rework: ⛔ fix the failure, ⛔ do not re-open the reviewed work.

    But fixing only the named line would have been the wrong move, and the dev didn't make it: it ran the gate and let the gate enumerate. check:esm-specifiers is repo-wide across all 39 published ESM packages, so plugin-kanban and plugin-detail are covered too, and diffing every import this branch adds against the merge base confirms the one fixed was the only relative import reaching published source — every other addition is a package specifier or lives in the three new test files, which no published build emits. ✅ That is a measured "only one", not an assumed one.

    The durable lesson, and it is wider than one gate

    The original miss was not a typo — it was a class. The dev's hand-derived gate set (correct practice here; this repo has no dispatch-gates.mjs) was built from top-level script names, and check:esm-specifiers runs as a step inside the Type Check CI job, under a name that does not read like a type check. Deriving the set from job step lists instead surfaced four more gates that had never been run on this branch — all now green: check:spec-symbols, check:action-forward-parity, check:icon-record-names, check:i18n-keys (plus check:phantom-deps, check:self-import, already run).

    This is now a standing rule in every dispatch from this seat: derive the gate set by enumerating each CI job's own step list, ⛔ not from top-level script names.

    One reading worth keeping — the two reds look alike

    pnpm --filter @object-ui/app-shell type-check came back EXIT=2 on its first run with error TS2307: Cannot find module '@object-ui/components'. That is the unbuilt-closure red, ⛔ not the missing-node_modules red and ⛔ not the edit: the worktree had been removed at the end of round one per the cleanup rule, so the recreated one had node_modules (install EXIT=0) but no built dist/. Rebuilding the closure then gave EXIT=0. ✅ Reported rather than quietly re-rolled — and the two signatures read almost identically, which is exactly why every dispatch from this seat now asks for them to be distinguished by name.

    Re-verified at the new head, not carried

    Path-filtered app-shell (src/utils/ plus the five ActionParamDialog/DeclaredActionsBar consumers) EXIT=0, Test Files 30 passed (30) / Tests 542 passed (542); packages/plugin-kanban/ packages/plugin-detail/ EXIT=0, Test Files 116 passed (116) / Tests 1049 passed (1049) — identity pins re-confirmed. Mutation proven on disk both ways before the run (injected './paramToField.js' present, removed './paramToField' absent), control-byte scan clean, working tree clean at push.

    Filed, not folded

    #6063 — ObjectKanban's resolveDisplay guard is unreachable: if (isLookup && isOpaqueId(raw)) return undefined; is immediately followed by if (isOpaqueId(raw)) return undefined;, which subsumes it for every input. So no membership delta on that face is observable — this is what made the first kanban identity pin unfalsifiable, and it is a different defect class needing a decision. ✅ Filed unassigned with finding rather than fixed in passing.

    Disclosure noted, not blocking

    The rework commit carries a Part of #5874 trailer. It cannot close anything (no closing keyword), the PR body remains the single closing declaration, and the branch will be squashed — so the trailer disappears at merge. Removing it would have required a force-push, which the dev correctly declined to do. ✅ Right call: the disclosure costs nothing, the force-push could have cost the branch.

    #5875 is folded into this PR — one defect at two levels; the false "fourth and last" claim is about the copies being converged, so splitting them would have shipped a prose correction describing a state that did not yet exist.


    Generated by Claude Code

  12. added a commit that references this issue on Aug 25, 2026
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