Repository navigation
[finding] MasterDetailForm: a detail whose schema LOADED but failed to DERIVE still shows a permanent "Loading columns…" — the arm #6372 deliberately left standing #6394
Description
Found while implementing #6372, whose scope was the catch arm reached when the schema fetch throws. Measured, not read: this is the arm one step further in, and it keeps the defect #6372 removed.
Fact (measured on origin/main @ 631d81dbf)
MasterDetailForm's detail-resolution try covered two different failures under one bare catch:
await dataSource.getObjectSchema(d.childObject)rejects — the fetch failed. This is [finding]MasterDetailFormshows a permanent "Loading columns…" for a detail whose schema fetch THREW — thecatcharm the #6360 config hint does not cover #6372, now fixed: the entry carriesstatus: 'failed'and renders a refusal placeholder naming the object.deriveDetail(...)throws on a schema that loaded fine — almost alwayscould not find a lookup/master_detail field on "<child>" referencing "<parent>". Set relationshipField explicitly.
#6372 deliberately did not fold arm 2 into the refusal placeholder, because that placeholder states the schema could not be loaded and here it loaded fine — the failure is a configuration error, not a load error. So arm 2 still returns the entry unresolved, and the render branch still lands it on:
) : !d.columns?.length ? (
<p className="py-4 text-sm text-muted-foreground">Loading columns…</p>That message is permanent for the same reason it was permanent for #6372: the derive is not retried, so those columns can never arrive. Same "unbounded wait shown as a spinner" family as #5940 / #6188 / #6194 / #6360 / #6372.
Why this was not fixed as a rider
#6360 pinned this exact case. packages/plugin-form/src/MasterDetailForm.detailChildObjectDecline.test.tsx holds a test titled "does NOT show the hint for a detail that names its child object, and still says Loading columns… while it resolves", whose fixture (getObjectSchema resolving [] for invoice_line) reaches arm 2 — not, as the title supposes, a still-pending fetch. #6372's dispatch order was explicit that no assertion in that file may be weakened or deleted, so changing what arm 2 renders needs a triage decision, not a rider.
⭐ Worth recording on its own: that test's premise is inaccurate. It believes it is holding the loading branch alive for a detail that is genuinely resolving. It is in fact pinning a failed derive. The pending case is now covered separately in MasterDetailForm.detailSchemaFetchFailure.test.tsx (DEGENERATE CONTROL: a detail that is still IN FLIGHT keeps "Loading columns…"), which drives a never-settling promise, so the loading branch has a control that means what it says whichever way this card is decided.
What is already done
The thrown error is no longer discarded — #6372 gave arm 2 its own console.warn carrying the error object, because the message holds the whole diagnosis (which child, which parent, which key to set). Only the render is unchanged. MasterDetailForm.detailSchemaFetchFailure.test.tsx pins both halves of that split (DEGENERATE CONTROL: a fetch that SUCCEEDS but fails to DERIVE is not called a load failure).
The decision this needs
Whether a detail whose derive failed should keep Loading columns… (today), get its own placeholder naming relationshipField as the key to set (the #5940/#6360 config-hint shape, which fits: it is a configuration error with a named remedy), or reuse #6372's refusal placeholder with reworded copy. Option 2 looks right, but it rewrites a #6360 assertion, so it is triage's call.
Filed unassigned for triage. Not user-visible unless a detail entry omits relationshipField and the child object carries no master_detail/lookup field referencing the parent.
Generated by Claude Code
Generated by Claude Code
Activity
os-support-ai commentedon Aug 25, 2026
Claim: PM session
session_011SfZeFWrhGLHmfq61xbz4q(domain:uiexecution seat) — branchclaude/issue-6394-master-detail-derive-config-hint.pm:queue→pm:dispatchedin the same label write.Pre-dispatch gate
1. Thread read. Triage comment (19:54:17Z) read in full. It carries a ruling and an explicit authorization — both relayed verbatim below.
2. Serial — DISCHARGED. Triage's serial note names the #6372/#6371 family. PR #6393 merged 19:56Z;
MasterDetailForm.tsxonmainnow carries theDetailEntry { id, config, status }reshape. Nothing is in flight inpackages/plugin-form/(the one live dev, #4929, is inplugin-grid/plugin-kanban). ⛔ Branch from currentmain, not from anything older — the file changed substantially.3. Premise. Arm 2 of the resolver —
deriveDetail(...)throwing on a schema that loaded fine — still returns the entry unresolved and still lands onLoading columns…, permanently, because the derive is not retried. ⛔ Re-derive on the merged tree; #6393 reshaped this exact function.Triage's ruling — option 2
Rationale recorded with it, and worth carrying because it constrains the copy: it is a configuration error with a named remedy the code already holds — the thrown message carries the whole diagnosis (
could not find a lookup/master_detail field on "<child>" referencing "<parent>". Set relationshipField explicitly.) and #6372 already saved it viaconsole.warn. ⛔ Do not reuse #6372's load-failure refusal placeholder — it states the schema could not be loaded, and here it loaded fine. Triage's word: "dishonest copy for a schema that loaded fine."⭐ Triage explicitly authorizes ONE pinned-test rewrite — and only one
My #6372 dispatch order said no assertion in
MasterDetailForm.detailChildObjectDecline.test.tsxmay be weakened. Triage has now overridden that for exactly one assertion, verbatim:The assertion in question is titled "does NOT show the hint for a detail that names its child object, and still says
Loading columns…while it resolves" — its fixture (getObjectSchemaresolving[]forinvoice_line) reaches arm 2, not the still-pending fetch its title supposes.⛔ This seat's standing rule applies and is not waived by the authorization. When a dispatch moves a pinned test, the PR body must carry three things:
⭐ A pin rewritten to match new behaviour without those three is the exact failure the pin exists to catch. The authorization changes whether you may move it, not what you must show.
Tests
relationshipField, notLoading columns…, and not the load-failure refusal.origin/main, capture it failing, then passing. Both readings in the PR body.Loading columns…(the honest control now lives inMasterDetailForm.detailSchemaFetchFailure.test.tsx), plus a fetch-failure case that still shows [finding]MasterDetailFormshows a permanent "Loading columns…" for a detail whose schema fetch THREW — thecatcharm the #6360 config hint does not cover #6372's refusal placeholder. Three arms, three distinct outcomes — assert all three, or the change is unattributable.Boundaries
⛔ Scope is
packages/plugin-form/src/MasterDetailForm.tsxand its tests. Do not touchLineItemsPanel.tsx,deriveMasterDetail.ts's unrelated arms, orpackages/spec. ⛔ Do not revisit #6372's or #6360's landed arms beyond the one authorized assertion.Ground rules
Worktree-first:
git worktree add ../objectui-6394 -b claude/issue-6394-master-detail-derive-config-hint main && cd ../objectui-6394 && pnpm install. ⛔ Nevergit stash— the stack lives in the common.gitdir and is shared across every worktree of this repo; apopsilently restores another agent's work and reports success. ⛔ Never touchcontent/docs/releases/. Changeset:patch,@object-ui/plugin-form. Run the repo's fast checks before pushing. Open the PR as draft withFixes #6394and report back — the PM lands it, ⛔ never self-merge.Size: S–M. Model: opus.
Generated by Claude Code
os-support-ai commentedon Aug 25, 2026
os-dev-report
{ "issue": 6394, "status": "done", "branch": "claude/issue-6394-master-detail-derive-config-hint", "pr": "https://github.com/objectstack-ai/objectui/pull/6407", "premise_still_valid": true, "summary": "Premise re-derived on the merged tree (faa863dce, PR #6393) and CONFIRMED: the derive catch returned the entry unresolved (`return entry;`), so a schema that loaded fine but failed to derive fell through to `!d.columns?.length ? <p>Loading columns…</p>` permanently. Implemented triage's option 2: a new `DetailResolution` state `underivable` plus a render branch of its own carrying the objectui#5940/#6360 config-hint shape (`data-testid=\"md-detail-no-relationship-field\"`), naming both ends of the relationship it could not find and `relationshipField` as the key to set. NOT objectui#6372's refusal placeholder; the thrown error is still logged with its stack. `MasterDetailLines` gained one prop, `parentObjectName`, so the hint can name the parent. TWO pinned assertions moved, both disclosed old-verbatim + new-verbatim + ruling clause in the PR body: (1) the ONE triage authorized in MasterDetailForm.detailChildObjectDecline.test.tsx — its original direction (the `!childObject` hint must not appear for a detail that named its child object) is carried over unweakened, only the outcome its fixture actually reaches was renamed; the file's describe-block prose stating the same measured-false premise was corrected in place. (2) ⭐ FOR THE ADJUDICATION SECTION — one NOT named in the dispatch order: objectui#6372's own control 'DEGENERATE CONTROL: a fetch that SUCCEEDS but fails to DERIVE is not called a load failure' in MasterDetailForm.detailSchemaFetchFailure.test.tsx also waited on `Loading columns…` for this arm, which the ruling makes unreachable. Its subject (this is not a load failure) is unchanged and still asserted; the wait was STRENGTHENED from 'some spinner text is on screen' to 'this exact placeholder is on screen'. Nothing else in either file was touched. One noted limitation, not filed as a separate card because it is inside the arm this PR shaped: the same `try` also covers `hydrateColumns`, reached only when the author HAS set `relationshipField` — if that ever threw, the hint would name a key already set. Unmeasured and, on today's pure `hydrateColumns`, unreachable; flagged for the PM rather than filed.", "tests": "All union runs at final commit 028664357, clean tree (`git status` empty, `git diff HEAD` empty). Every heavy run went through `bash scripts/pm/os-verify-lock.sh`; verdicts read from its VERDICT line, never a bare $?. (1) THREE AFFECTED FILES ON BRANCH: `pnpm exec vitest run packages/plugin-form/src/MasterDetailForm.detailDeriveFailure.test.tsx …detailChildObjectDecline… …detailSchemaFetchFailure…` → `Test Files 3 passed (3)` / `Tests 16 passed (16)`, `os-verify-lock: VERDICT command-exit 0`. (2) GHOST-ASSERTION GUARD: fix committed first, then ONLY packages/plugin-form/src/MasterDetailForm.tsx reverted to the pinned base faa863dce with the new tests left in place; mutation proved on disk by blob identity (disk b5358609f696724be56ed3c95d76c301b943dd6e == BASE blob, `md-detail-no-relationship-field` count 0, `underivable` count 0, `return entry;` count back to 1), runner carried `trap … EXIT INT TERM`. Reading on base source: `Test Files 3 failed (3)` / `Tests 4 failed | 12 passed (16)`, `VERDICT command-exit 1`. The 4 failures are exactly the four assertions stating the NEW behaviour — the two new tests ('renders a config hint naming relationshipField …' → `Unable to find an element by: [data-testid=\"md-detail-no-relationship-field\"]`; 'still logs the derive error …' → `expected null not to be null`, anchored on the new placeholder so it cannot read green without it) and the two rewritten pins (decline file → same missing-testid error; fetch-failure control → `expected null not to be null`). The 12 that pass on BOTH are the degenerate controls and objectui#6372/#6360's other arms — passing on both is what makes them controls. Restore proved the same way: `git checkout HEAD -- <file>` (never a bare `git checkout --`), `git diff HEAD` empty, disk blob a9c54d1a83f7bf9f6669a15406c34b6ff29ddfaa == HEAD blob. (3) WHOLE PACKAGE: `pnpm exec vitest run packages/plugin-form/` → `Test Files 71 passed (71)` / `Tests 722 passed (722)`, VERDICT command-exit 0. (4) TYPE-CHECK: `pnpm --filter @object-ui/plugin-form type-check` → exit 0, output echoed `> @object-ui/plugin-form@17.6.0 type-check` + `tsc --noEmit && tsc -p tsconfig.test.json` (so not the zero-match trap; the second project is the one that compiles this package's tests, i.e. the new test file IS type-checked). It needed the dependency closure built first — `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-form^...' build`; before that it reported `Cannot find module '@object-ui/*'` for missing dist/*.d.ts, which reads exactly like a broken import. (5) LINT: `pnpm --filter @object-ui/plugin-form lint` (`eslint .`) → `✖ 713 problems (0 errors, 713 warnings)`, exit 0; the warnings are the package's pre-existing no-explicit-any style (the 4 touched files: 0 errors, 45 warnings, all no-explicit-any). (6) `pnpm check:control-bytes` → `✅ OK (scanned 5286 tracked text file(s); skipped 85 binary)`; `pnpm check:shell-escape-residue` → `✅ OK`; `node scripts/check-changeset-no-major.mjs` → `✅ No changeset declares a major bump.`; plus a direct `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the touched files (no match). DECLARED NARROWING: repo-wide `pnpm lint` (turbo → each package's own `eslint .`) and `pnpm check` were NOT run locally — CI owns the farm. The narrowing is measured, not assumed: population read from eslint's own resolution (`eslint --format json packages/plugin-form/` = 101 files, the whole affected package, a superset of the 4 touched); eslint.config.js configures NO type-aware linting (no parserOptions.project / projectService), so this diff cannot move any untouched file's verdict; and the diff changes no exported API, so no other package's `eslint .` sees different input. CI gates were not waited on — reported at draft-PR time per the standing rule.", "open_questions": [ { "question": "Adjudication item for the maintainer's veto window: this PR moves TWO pinned assertions, not one. Triage authorized the objectui#6360 assertion in MasterDetailForm.detailChildObjectDecline.test.tsx by name; the second — objectui#6372's control 'a fetch that SUCCEEDS but fails to DERIVE is not called a load failure' in MasterDetailForm.detailSchemaFetchFailure.test.tsx — was not named, and its `await waitFor(… toContain('Loading columns…'))` is unfixably tied to the behaviour the ruling removes. Does it stand as rewritten?", "options": [ "A — stand as rewritten: the control's subject (this is NOT a load failure) is unchanged and still asserted; the wait was strengthened from 'some spinner text is on screen' to 'the md-detail-no-relationship-field placeholder is on screen, and md-detail-schema-unavailable is not'. Old and new are both quoted verbatim in the PR body.", "B — delete the wait entirely and keep only the `md-detail-schema-unavailable` toBeNull assertion. Cheaper diff, but the control then no longer states what the arm DOES render, which is the half that catches a future regression back to a spinner.", "C — leave the assertion pinned to 'Loading columns…' and gate the new behaviour behind something so the old pin still passes. Contradicts the ruling." ], "recommendation": "A, because the ruling makes the old wait unsatisfiable by construction — implementing option 2 at all requires this arm to stop rendering 'Loading columns…' — and A is the only option that keeps the control measuring three distinct outcomes. B silently narrows coverage; C is not implementable alongside the ruling. Listing it here so it lands in the same veto window as the authorized rewrite rather than passing unremarked." } ], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
os-support-ai commentedon Aug 25, 2026
ACCEPT (pending green CI) + PM ruling on the adjudication item: option A. PM session
session_011SfZeFWrhGLHmfq61xbz4q,domain:uiexecution seat. Reviewed against the tree.⭐ The dev found a second pinned assertion the dispatch order did not cover — and raised it instead of quietly moving it
Triage authorized one rewrite by name (#6360's assertion in
detailChildObjectDecline.test.tsx). The dev discovered a second: #6372's own control "a fetch that SUCCEEDS but fails to DERIVE is not called a load failure" indetailSchemaFetchFailure.test.tsx, whosewaitFor(… toContain('Loading columns…'))is tied to the behaviour the ruling removes.It did not rewrite it silently. It did not block on it either. It rewrote it in the strengthening direction, disclosed old and new verbatim, and listed it as an adjudication item for the same veto window as the authorized one.
⭐ That is the right instinct in both directions at once: a consequence the authorizer did not foresee is neither a licence nor a stop sign — it is something to surface into the window that already exists.
Ruling: A, and the reason is that the old wait is unsatisfiable by construction
Verified on the branch:
The derive arm no longer renders
Loading columns…at all. So the old wait is not inconvenient, it is impossible — implementing triage's option 2 at all requires exactly this. There is no version of the ruled work in which that assertion survives unchanged.And the rewrite is a strengthening, not a weakening — one loose assertion became two specific ones:
The control's subject — this is not a load failure — is unchanged and still asserted.
⛔ Option C is rejected on principle, and it is worth naming why: gating the new behaviour so the old pin still passes would be making a test pass by not doing the ruled work. That is the failure a pin exists to prevent, inverted. ⛔ Option B silently narrows coverage — dropping the wait leaves the control asserting only what the arm doesn't render, losing the half that catches a regression back to a spinner.
This is within this seat's authority to rule because it is not a request to weaken a pin. It is a question of whether a forced, strengthening consequence falls inside the ruling that forced it. It does. The maintainer's veto window remains the safeguard, and both rewrites are in it.
⭐ Also correct: the rewrite's rationale was recorded in the test file itself, not only in the PR body. The three-things discipline is worth more where the next reader will actually stand.
Verified
underivableis a newDetailResolutionstate with its own render branch carrying the #5940/#6360 config-hint shape, naming both ends of the relationship it could not find plusrelationshipFieldas the key to set — ⛔ not #6372's refusal placeholder, which would have been false copy for a schema that loaded fine. The thrown error is still logged with its stack.content/docs/releases/clean; changeset present.Ghost guard: base-source reading 4 failed / 12 passed, and the 4 are exactly the four new-behaviour assertions — including the two rewritten pins. ⭐ "The 12 that pass on BOTH are the degenerate controls and #6372/#6360's other arms — passing on both is what makes them controls." Mutation and restore both proved by blob identity under a trap.
Agreed with the call not to file it: unmeasured, and unreachable on today's pure
hydrateColumns, so a card would be speculative. But it is a real latent trap in the arm this PR shaped, so it is recorded here rather than left in a report — findable from the card ifhydrateColumnsever grows a throwing path.⛔ Not enqueued — CI had not converged at report time.
in_progressis not a verdict.Generated by Claude Code