fix(plugin-form): the line-items panel neither draws nor saves the lines it holds for another parent record (objectui#10740) - #10751
Conversation
…nes it holds for another parent `LineItemsPanel` now records which parent its held rows belong to, in the same commit as the rows. While that parent is not the current `parentId` (a load for the new parent failed or declined), no held line is drawn, the Save button is off and `save` returns before it builds a batch. A load for the new parent that succeeds, and a same-parent re-load that fails, behave as before. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
… the Save button's disabled state React filters a click on a button whose props say disabled, whatever the DOM node says, so that row never reached `save` and passed for the wrong reason. The function's own guard is unreachable through the rendered UI while the button's guard holds; the header says so instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
CI note (
Generated by Claude Code |
Contract reviewServed-tier: ① Derived judgmentsSetup: detached worktree at the head (panel blob Write paths from the held
Same-parent failed re-load control. Pin row 5 green on the base blob and at head, with the same batch Pins (
The placeholder. Sibling pins. The seven ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL What stops it: the R7 route in ①. A line typed while Replacement, code (inside Generated by Claude Code |
…ad settled belongs to the parent on screen The panel's `onChange` now writes the rows' owner beside the rows when nothing is held yet, so a line typed under an adapter-less mount on p1 is not adopted by the first parent whose load later fails, and Save under p2 sends nothing. The owner is written wherever the rows are written; the comment, the changeset and the pin say so, and the pin gains the adapter-less row. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
ℹ️ Console Performance Budget — not measuredThis run did not produce a console bundle to measure, so there is no pass/fail verdict for the performance budget. This is not a budget violation. Nothing was measured — the numbers a real violation would carry are simply absent.
See the workflow run for details. No package size report: it is only generated from a complete package build, so a partial one is never shown. |
…loses over it The edit-owner adoption `setRowsHeldFor((held) => held ?? parentId)` widened the setter's `string | undefined` state to include `null`: parentId is `string | null | undefined` in onChange, with no load guard above it to narrow it, so tsc and the dts build rejected it. Capture the narrowed value in an `if (parentId)` block; the grid is only offered with a parent bound, so the guard loses no coverage. Semantics unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: ① Derived judgmentsSetup: detached worktree at the head (panel blob The delta R7 at head (probe and pin, both green): adapter-less mount on p1, type Breaking the
R1–R6 and the same-parent control re-run at head: all green, readings as in round 2 (R1 Observation, unpinned and unstated (B5): a first load for p1 that fails with nothing typed, then a swap to p2 whose load fails, draws the placeholder and no grid at head; base drew the empty editable grid under p2's banner. Since Pins ( Siblings: the seven Type-check and build at head: closure build ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL What stops it is the replacement body, not the code. The code closes R7 (measured at head, red on both earlier blobs, reddened by A6 alone), no break of the |
…name each refused state precisely Prose and comment only; no code token moves. The changeset parenthetical now separates the three refused states (failed load: placeholder under the banner; refused-filter decline: that filter's own notice; lost-adapter decline: the placeholder alone), and the onChange comment no longer claims the adapter-less grid is the only grid offered before a load settles. Frontmatter byte-identical. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: ① Derived judgmentsSetup: REST reads and The delta Re-measured figures the body carries. Against the merge-base ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL What stops it is one false evidence figure in the replacement body, not the code and not the commit: |
Contract reviewServed-tier: ① Derived judgmentsSetup: REST reads and Head unchanged. The PR head is The diff Re-measured merge trees of The Serial sentence. Every figure in it is true as dated: Markdown. Line 112 now reads "One commit, Other lines against each other and the head. Title equals the changeset's title line at head. H1, pin table and Gates headings all read ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ding it hides (objectui#10746) (objectstack-ai#10762) Fixes objectstack-ai#10746 Clause-②: no — a Studio inspector's patch on a type switch; no declared surface moves ## What changed `packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx`: the Report type picker's commit is now `commitType`. When the picked type is `joined`, the patch that commits `type: 'joined'` also carries `dataset`, `values`, `rows`, `columns`, `chart` and `order` as `undefined`-valued keys — only those the draft actually holds (`draft[key] !== undefined`). Any other type commits `{ type }` alone, as before. The list lives in `JOINED_CONTAINER_CLEARED_KEYS` with the reasoning beside it; `blocks`, `runtimeFilter` and `drilldown` are never named. `order` is a sixth key, beyond the card's five, under the role file's bounded in-place exemption (same defect class, mechanical, same file, same gate family, no other claim on the file): the spec's own `reportForm` hides `order` in the same "Dataset binding" section (`visibleWhen: "data.type != 'joined'"`), and the INSTALLED spec already refuses a container `order` on a joined report ("a `joined` report orders per block — move `order` onto `blocks[]`"), so the same invisible-key save refusal exists today for `order`. Evidence in H3 below. New pin file `ReportDefaultInspector.joinedClearsBinding-10746.test.tsx` (8 tests); changeset `.changeset/10746-joined-report-clears-binding.md` (`'@object-ui/app-shell': patch`). ## H1 — reproduction on `origin/main` (`1422a920ed`) The pin file run against the untouched component: `Tests 4 failed | 4 passed (8)`. The defect pin quotes the only `onPatch` argument the type picker commits today: `AssertionError: expected { type: 'joined' } to strictly equal { type: 'joined', …(6) }` — the commit is `{ type: 'joined' }` and nothing else. The 4 pins green on base are the boundary/control pins, which pin what must NOT change. ## H2 — the clear mechanism `onPatch` is a shallow patch in all three hosts: `ResourceEditPage` applies it as `handleDraftChange((d) => ({ ...d, ...patch }))`, `ReportConfigPanel.handlePatch` as `{ ...draftRef.current, ...patch }`, and `StudioDesignSurface.onPatch` as `setDraft((d) => ({ ...d, ...patch }))`; each saves through `client.save`, which is `JSON.stringify`. None offers a delete sentinel, so the one way to drop a key is an `undefined`-valued key — the spelling this inspector's own `commitChart` (`chart: next.type ? next : undefined`) and the siblings (`ActionDefaultInspector`, `ObjectDefaultInspector`, `DatasetDefaultInspector`'s "Clear all", pinned by its objectui#9372 suite) already use. Measured in the second pin: after the spread the key is an OWN property holding `undefined` (`Object.hasOwn` true, value `undefined` — not `null`, not an empty string), and `JSON.parse(JSON.stringify(committed))` — the shape `client.save` puts on the wire and the spec parses — has no such property. The spec's refinement itself skips `undefined` (`if (value === undefined || …) continue;` in the joined arm), so even the in-memory draft would not be refused. - Reach: The clear holds for the patch and for the save that follows it. The metadata-admin editor rebuilds its draft as the served draft spread over `layered.effective` (on load, after each save and after publish), and `effective` is the published layer, so a report whose PUBLISHED version was bound gets those keys back in the draft after the first draft save until it is published, and a report already saved `joined` with stale keys is not repaired on load. Both are the host's draft-over-baseline merge, objectui#10765. ## H3 — the spec check Installed `@objectstack/spec` is 17.4.0 (`packages/app-shell/node_modules/@objectstack/spec`). It PREDATES objectstack-ai/objectstack#20160: `grep 'selects per block'` over its `dist/` gives 0 hits; the control `grep 'orders per block'` gives 3. One-off probe with the installed `ReportSchema.safeParse` (not committed): - joined + one block: success - joined + stale `dataset` / `values` / `rows` / `columns` / `chart`: **success** — 17.4.0 accepts them - joined + stale `order`: refused, `custom` at `['order']`, "a `joined` report orders per block — move `order` onto `blocks[]`." - joined + `blocks: []`: refused at `['blocks']` - joined + `runtimeFilter` + `drilldown: false`: success - joined + `dataset: undefined` as an own key: success So the pins' parse leg measures the `order` half with the installed spec (before the fix: refused at `['order']`; after: parses), and for the four selection keys the pins assert ABSENCE and cite the rule read at objectstack `origin/main` `packages/spec/src/ui/report.zod.ts`: `JOINED_CONTAINER_SELECTION_KEYS = ['dataset', 'rows', 'columns', 'values']`, one `custom` issue per present key at `path: [key]`, message "a `joined` report selects per block — move `KEY` onto `blocks[]`, or delete it; on the container it selects nothing." (`KEY` stands for the key's own name.) No checkout on this box holds a built spec `dist` carrying that refusal, and building one was outside this card. ## H4 — edges - `joined` → non-joined: nothing is restored; the patch is `{ type }` alone and the author re-binds (pinned). - non-joined → non-joined: the binding is kept; the patch is `{ type }` alone (control, pinned). - `joined` with `blocks[]` present: untouched, same array reference, in both directions (pinned). - Undo: there is no undo stack behind the metadata draft. `ResourceEditPage`'s `Undo2` button is `doDiscardDraft` (ADR-0034: discard the whole pending draft), not a per-edit undo, and `handleDraftChange` keeps no history. One undo cannot restore the keys; switching the type back does not either. - `runtimeFilter` and `drilldown` survive the switch (pinned): the joined branch reads both. - Partially bound report: the patch names only the keys present (pinned), so `ReportConfigPanel.onFieldChange` sees no phantom clears, and an unbound report's switch stays `{ type: 'joined' }` (pinned). ## Pins and ablations Head `3f53c15776`, pin file plus the two existing `ReportDefaultInspector` suites: `Test Files 3 passed (3) · Tests 37 passed (37)`. Red on base (`1422a920ed`, component untouched, pin file present): `4 failed | 4 passed` — the defect pin, the own-key/serialised pin, the parse leg, the partially-bound pin. The other 4 are controls and boundaries, green on base by construction. Per-hunk ablations on the committed head, each through objectstack's `scripts/ablation-replace.mjs` (anchor must hit exactly once; the blob change is verified on disk; restore proven `blob == HEAD` `a9cb7e9c` and `git diff HEAD` empty), the prediction written before each run, observed direction = predicted: - M1, drop the `joined` guard (`if (nextType === 'joined')` becomes `if (typeof nextType === 'string')`): predicted CONTROL F red; observed `1 failed | 7 passed` (CONTROL: non-joined → non-joined keeps the binding). - M2, drop the presence filter (`if (draft[key] !== undefined) patch[key] = undefined;` becomes unconditional): predicted D and E red; observed `2 failed | 6 passed` (names only present keys; unbound one-key patch). The first attempt was a NO-OP the tool refused — the replacement text was a substring of the anchor, so its on-disk count could not rise (`1 to 1, a rise of 0`) — and it restored; the leg was re-run with a distinct replacement. - M3, add `blocks` to the cleared list: predicted C and G red; observed `2 failed | 6 passed` (parse leg: the joined report loses its blocks; CONTROL: blocks untouched). - M4, drop `order` from the list: predicted A, B and C red; observed `3 failed | 5 passed`. Assertion spelling: every patch is read with `toStrictEqual`, because `toHaveBeenCalledWith` / `toEqual` treat an `undefined`-valued key as absent and would read the defect and the fix alike. Round 2 (`d4945789f6`, the contract-review wording round: the changeset's reach sentence and the pin file's three-host comment; no source line changed — the diff's only non-changeset lines are the comment lines shown by `git diff -U0`, and `ReportDefaultInspector.tsx` is untouched): pin file re-run `Tests 8 passed (8)`. ## Gates (local, derived by hand from `package.json` and the workflows — objectui has no `dispatch-gates.mjs`) - ① closure build `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`: `Tasks: 28 successful, 28 total`, 2m12s, under the verify lock. - ② `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0; `tsc -p tsconfig.test.json --listFiles` lists the pin file (1 hit among 4673 files). - ② vitest, the three files above: 37 passed; round 2 pin file: 8 passed. - `node scripts/check-changeset-presence.mjs`: ✅ (round 2: 2 source files of 1 released package changed, 1 changeset declared). `node scripts/check-changeset-no-major.mjs`: ✅. `pnpm check:changeset-claims`: ✅. `pnpm check:pending-changeset-literals`: ✅. - `pnpm check:control-bytes`: ✅ OK (round 2: 8942 tracked text files). The role file's control-byte grep over the changed files: no match. - `pnpm check:new-line-citations`: `0 new citation(s)` (both rounds). - `pnpm check:test-path-roots`: ✅ OK. `pnpm check:vi-mock-specifiers`: ✅ OK. - eslint with the package config on the two touched `.tsx` files: exit 0. - NOT MEASURED locally, CI-owned: `pnpm lint` (repo-wide), the 8 test shards, `Build & E2E`, `check:i18n-*` (no `t()` key was added or changed). Round 1 CI on `3f53c157`: 40 success, 3 skipped, 0 failed per the contract review. ## Serial Round 2: `origin/main` at `25c7d584e4` (+5 commits over BASE `1422a920ed`: objectstack-ai#10752, objectstack-ai#10753, objectstack-ai#10708, objectstack-ai#10751, objectstack-ai#10761); none touches the three files; `git merge-tree --write-tree` exits 0 (clean, tree `738f2bf534`). No merge commit was needed. ## Acceptance notes - `chart` is cleared per the triage direction and objectstack-ai/objectstack#20161 (inert on a joined container); the installed spec does not refuse it, so its clearing is pinned by absence only. - Deviation: the worktree-creating `git fetch origin main` ran against the shared checkout (the role-file recipe) rather than inside the worktree as the order asked; `origin/main` did not move (`1422a920ed` before and after), only the shared checkout's `FETCH_HEAD` was touched. Every later fetch used a private ref inside the worktree. - Observation, not filed: `ReportConfigPanel` hosts this inspector too, merges with the same spread and emits `onFieldChange(key, undefined, next)` once per cleared key — the documented "field changed" signal, correct for a live preview. - The draft-over-baseline merge that re-introduces a published binding after the first draft save is objectui#10765 (filed by the seat), not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #10740
Clause-②: no
A component's internal state and its Save guard; no declared surface moves.
@object-ui/plugin-formpublishes.only, and no export, type, prop or registration changes. Dispatched by thedomain:uiseat 2, sessionhttps://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN(mode:subagent), executing triage5852239622. Round 3 closes one residual write path (R7) the contract review found; the Round 3 section at the foot carries its head and evidence.What changed
packages/plugin-form/src/LineItemsPanel.tsx(+67 / −4 against base). The panel now records which parent its heldrows/originalbelong to: oneuseState,rowsHeldFor, written wherever the rows are written — beside a load's commit (setRowsHeldFor(parentId)next tosetRows/setOriginal/setDirty(false)), inside the CURRENT load'scatchthat fails while nothing is held (an updater that keeps a value already held and otherwise adoptsparentId, insideisCurrent()), and inonChangefor an edit made before any load has settled (the same keep-or-adopt updater,parentIdnarrowed to a string first). One predicate,heldForAnotherParent = rowsHeldFor !== undefined && rowsHeldFor !== parentId, is read at three sites: the Save button'sdisabled, the first statement ofsaveafter its existingdataSource/parentId/childObjectguard (it returns beforesetSaving, before any batch is built), and the render, where a placeholder (data-testid="line-items-held-for-another-parent") stands in for the grid ahead ofLineItemsField.save's andonChange's dependency lists gain what they read (heldForAnotherParent,parentId).setRows/setOriginal/setDirty(false)) and theloadingrelease (objectui#10712's PR 2 surface), theloadSeqRef/isCurrent()error rule from PR objectui#10738,save's two writes toerror, every other dependency list, every hook call, prop, export and registration. The commit block gained thesetRowsHeldFor(parentId)line and thecatchwas re-braced to add the adoption; the three commit statements themselves did not move.packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx(new, +389 / −0): nine rows through the realSchemaRendererandrecord:line_items, everyfindandbatchTransactiona deferred promise settled by hand..changeset/10740-line-items-held-for-another-parent.md(new, +24 / −0,'@object-ui/plugin-form': patch).H1 — the reproduction on base
5ad3b88861, and its controlsA throwaway probe (deleted, never committed) through the real
SchemaRendererandrecord:line_items, ids explicit. The column islabel(aria-labelLine), the child objectpo_line, the relationship fieldpo. Readings at base, then at the current head854e0922cc(whose only change sinceef16b93486is prose: the readings are the pin's, re-run green there):5ad3b88861854e0922ccp1answersl1, edited top1 line edited; the host re-renders withparentId: 'p2';p2's load rejectsp2 load failed; then Savep2 load failed; drawn lines['p1 line edited'], inputs enabled; Save enabled; Save ⇒batchTransactionops[{object: 'po_line', action: 'update', id: 'l1', data: {label: 'p1 line edited', po: 'p2'}}](find2 calls,batchTransaction1)p2 load failed; drawn lines[], no grid, the placeholder drawn; Save disabled; no batch (find2,batchTransaction0)p1with NO adapter (loadreturns at!dataSource, the grid is drawn,rowsHeldForstays undefined); typetyped under p1; the host moves top2, still no adapter; the adapter arrives andp2's load fails; then Save['typed under p1'], Save enabled; Save ⇒[{object: 'po_line', action: 'create', data: {label: 'typed under p1', po: 'p2'}}]— a p1-typed line created under p2[], Save disabled, no batchp2's load answersl2p2 line['p2 line'], no banner, Save disabled (nothing dirty); editl2+ Save ⇒[{object: 'po_line', action: 'update', id: 'l2', data: {label: 'p2 line edited', po: 'p2'}}]p1's edit held, a host re-render withlimit: 200re-runsp1's load, which rejectsp1 reload failed['p1 line edited'], inputs enabled; Save enabled; Save ⇒[{object: 'po_line', action: 'update', id: 'l1', data: {label: 'p1 line edited', po: 'p1'}}]p1's edit held, a re-render withparentId: 'p2'and a refused filter ({note: {$regex: 'a.c'}}); the load declines before any fetch[{object: 'po_line', action: 'update', id: 'l1', data: {label: 'p1 line edited', po: 'p2'}}](find1 call: the load never ran)The first row is the card's reading, op for op. R7 and the DECLINE row are two routes the card did not name: R7 is a line typed under a grid drawn with no adapter, adopted by the first parent whose load later fails; the DECLINE row reaches the guard through the load declining before its
catch. Both are closed by binding the owner to the rows wherever the rows are written.H2 — the shape: refuse by
rowsHeldFor; nothing is clearedThe triage offered two shapes, "clear
rows/original" or "refuse Save". This PR refuses, on a value written wherever the rows are written, and clears nothing. Why, measured:p2with a refused filter (and, by the same code path, a lostdataSource) declines the load before itstry, so a clear in thecatchleavesp1's held rows saveable underp2. The guard reads the rows' own parent and closes this path too (pinned; red at base).rowsHeldFormoves whererowsmove (the load's commit,onChange) and once more where a CURRENT load's failure leaves the empty rows on screen, so the two cannot disagree. This also covers the one-render window between the host re-render that movesparentIdand the effect that starts the new load:rowsare stillp1's,rowsHeldForstill saysp1, Save is off in that frame too. R7 is the case where this failed at the round-2 head:onChangemoved the rows without movingrowsHeldForwhile it was unset, so a line typed under an adapter-less mount stayed ownerless until the next failure adopted it.onChangenow writes the owner too.p1→p2(failed) →p1(failed) drawsp1's held edit again underp1(pinned as a control, "a swap BACK to p1"). A clear on thep2failure would have discarded that edit.onChange, R7's fix), or the empty rows a CURRENT load's failure leaves on screen (thecatch, insideisCurrent()). Either way a line the panel then holds belongs to the parent it was entered under, not to a parent the panel later moves to. Measured at base: a first load forp1fails, a line is added, the panel moves top2whose load fails ⇒ the added line is drawn underp2with Save enabled (the defect, as acreate). Pinned; red at base. A superseded failure adopts nothing (thecatchis insideisCurrent()).The function-level guard in
saveis the write contract's own check, on the function every caller reaches, in the same spirit as thechildObjectguard beside it. It is redundant on the UI path by design: the button and the function read the same render's predicate, so no click through the rendered UI reachessavewhile the button is off. See A2 below.H3 — every path from the held rows to a write
save→buildMasterDetailEditBatch(parentObject ?? '', parentId, parentPatch, [{rows, original, childSchema, …}])→runBatchTransaction. Guarded at both readers: the button'sdisabledand the function's first statement. The rollupsumRows(rows, …)is computed inside the same function, behind the same guard.onChange→setRows(next),setDirty(true), and the owner adoption. While the grid is drawn,rowsHeldForis the current parent — set by the load that offered the grid, or, when the grid was offered before any load settled, by this edit itself — so an edit belongs to the parent on screen, never to a parent the panel later moves to. In the refused state the grid is not drawn (ablation A3 re-opens exactly this and the "no p1 line is drawn" rows go red).setRows/setOriginal/setDirty(false)/setRowsHeldFor(parentId): the writer that defines whose the rows are on a successful load.LineItemsField(GridFieldin@object-ui/fields):grep -c useEffectonGridField.tsxat this base = 0; everyemit/onChangecall sits in a user handler (setCellValue,addRow, the remove handler, the reorder handler), all routed through the panel'sonChangein (2). Nothing changes rows without user input, the reading PR objectui#10650 made and this PR re-took.schemaonly; therecord:line_itemsregistration (LineItemsPanelRendererinsideElementDataSourceGate) passes nothing else, and the post-saveload()reads no rows. NoforwardRef/useImperativeHandle/ key handler;saveis referenced once, atonClick={save}.So there is one write path (
save), guarded twice, and one edit path (onChange), which brands every row it writes with the parent on screen. R7 is closed: a row can no longer reach a write bearing a parent it was not entered under, whether it was typed before a load settled or held from a previous parent — in the refused state the grid is not drawn, and in every drawn state the owner is unset or equalsparentId, and the first edit sets it toparentId.Pins and ablations
LineItemsPanel.heldForAnotherParent-10740.test.tsx, nine rows. Base and ablation legs ran on COMMITTED source (ef16b93486, panel blob5884928c7a) underos-verify-lock; the one later commit,854e0922cc, moves prose only (no code token, proof in the Round 3 section), and the pin is re-run green there. Each mutation went through objectstack'sscripts/ablation-replace.mjsin WRAP mode (anchor declared to hit once, counts and blob hashes read back from disk, restore spelledgit checkout HEAD --on the absolute path and proven by blob == HEAD and an emptygit diff HEAD). Two whole-file base legs, each swapped in by hash under a shelltrapand restored the same way: the card base blob0c498ce007(held ?? parentIdoccurrences 0), and the round-2 head blob47696a5819(occurrences 1 — the fix before R7 was closed). No build is on the resolution path: the pin imports./indexrelatively and every@object-ui/*specifier resolves tosrcthrough the root vitest alias, soablation-dist-preflightdoes not apply.0c498ce00747696a5819854e0922ccl2underp2and nothing ofl1l1underp10c498ce007:Failed Tests 5(the four refusal rows plus the recovery row), 4 green. Restore proven (blob == HEAD5884928c7a,git diff HEADempty).47696a5819:Failed Tests 1— exactly the R7 row. This is the one the round-3 close fixes; every other row was already green at the round-2 head. Restore proven.|| heldForAnotherParentremoved: 4 failed (the three original "Save off" rows and R7).saveguard neutralised (if (false && heldForAnotherParent) return;): 0 failed, 9 passed. As under H2: with the button guard in place no UI click reachessave, so this hunk is observable only together with A1. Kept as the write contract's own guard, not claimed as pinned.false && heldForAnotherParent ?): 4 failed (the two "not drawn" rows, R7 and the recovery row).setRowsHeldFor(parentId)deleted from the load commit: 2 failed — the "swap to p2 SUCCEEDS" control and the recovery row, both atexpected [] to deeply equal ['p2 line']: with the commit no longer branding the rows,rowsHeldForstays at the value the p1 edit adopted, so p2's own freshly loaded lines are refused behind the placeholder.catchfailure-adoption deleted: 0 failed, 9 passed. Since Round 3,onChangealso adopts, so every row whose owner thecatchused to set now has it set by the typed edit (or is an empty grid, never saveable). Thecatchadoption is retained as the documented adopt-on-failure behavior; like A2 it is kept and not independently pinned. (At the round-2 head, beforeonChangeadopted, A5 reddened the "line added under a FIRST load that failed" row.) Its one remaining observable effect is the B5 Acceptance note below.onChangeowner adoption deleted (setRowsHeldFor((held) => held ?? owner);): 1 failed — exactly the R7 row. This is the ablation of the round-3 close.ok mutation landed: anchor 1 to 0with the blob change, andok restored: blob == HEAD (5884928c7a…) and git diff HEAD is empty; the tree was clean before and after (git status --shortempty).Gates at head
854e0922ccLocked runs (
os-verify-lock,VERDICT command-exit 0on each):turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2:Tasks: 11 successful, 11 total.pnpm --filter @object-ui/plugin-form run type-check: exit 0 withtsc --noEmit && tsc -p tsconfig.test.jsonechoed;--listFilesOnlycounts the pin once in thetsconfig.test.jsonprogram andLineItemsPanel.tsxonce in the build program.pnpm --filter @object-ui/plugin-form build: exit 0,[unplugin:dts] Declaration files built,dist/index.jsemitted — the dts plugin fails on any type error and did not. The three built artifacts are byte-identical before and after the wording commit:dist/index.jssha2569e08b5589db5…,dist/index.d.ts08fe75fa41b4…,dist/index.umd.cjs19fddff1b34f…, built atef16b93486and again at854e0922cc.Tests 9 passed (9). The seven existingLineItemsPanel*pins plusrecordFormsErrorClears-10682.test.tsxatef16b93486:Test Files 8 passed (8),Tests 102 passed (102)(the later commit moves no code token).pnpm exec vitest run --maxWorkers=2 packages/plugin-form/, at7352890ae4(round 1):Test Files 131 passed (131),Tests 1507 passed | 1 skipped (1508). The later commits touch onlyLineItemsPanel.tsx, its pin and the changeset, covered by the pin and sibling runs above.Light gates, each exit 0 at
854e0922cc:check:new-line-citations(VERDICT new-cross-file-line-citations: 0 new citation(s)),check:control-bytes; and atef16b93486(no source token moved since):check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:shell-escape-residue,check:i18n-keys,check:handler-key-reads,check:element-data-source-declaration,check:self-import,check:unreferenced-sources,type-check:coverage,lint:coverage,check:phantom-deps,check:published-tsconfig-exclude,check:metadata-write-doors. Changeset gates at854e0922cc:check-changeset-presence(2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)),check-changeset-claims(No pending changeset names a file this change touches),check-changeset-overwrite(No pre-existing changeset was modified or deleted),check-pending-changeset-literals;check-changeset-no-majorandcheck-changeset-fixedatef16b93486.check-governed-merges.mjs --testover the three paths: NOT governed (0 of 3 paths hit the register).eslint, narrowed to the 2 changed ts/tsx files (
eslint --no-inline-config --format json): exit 0, the JSON counts 2 files, 0 errors. Narrowing evidence: the population iseslint.config.js'sfiles: ['**/*.{ts,tsx}'];--print-configonLineItemsPanel.tsxshowsparserOptions: {}and noproject/projectService, so no rule is type-aware and this diff cannot move the verdict of a file it does not touch.LineItemsPanel.tsxcarries 18 warnings, the same count and rule multiset as its base blob (14no-explicit-any, 2set-state-in-effect, 2exhaustive-deps); the pin carries 12no-explicit-any.NOT MEASURED locally, declared to CI: the repo-wide
pnpm lintand the rest of the farm.Serial
At claim time no open PR touched
LineItemsPanel.tsx(claim5852470959). Before the round-1 pushesorigin/mainwas01700dcd7d(four commits past the base); before the round-4 push it is704e05b09d, nine commits past the base.git merge-tree --write-treeagainst it exits 0 with a clean tree (be8cdeefbd); at the round-5 revieworigin/mainisf308a655b8(#10708, eleven commits past the base), against which the tree is01802fbba7, also clean. None of the eleven touches a file this branch touches (#10708 touchedskills/objectui/only); #10752 (704e05b09d) touchedpackages/plugin-formelsewhere (six form arms,recordInvalidation.ts,formArmsInvalidation-10715.test.tsx, and the header comment of the sibling pinrecordFormsErrorClears-10682.test.tsx), and #10749 movedpnpm-lock.yaml. objectui#10712's PR 2 is serial behind this PR, as the claim says.H5 — pending changesets
git grepover.changeset/forLineItemsPanel,line-items,line items: 14 pending files. The sentences that describe this panel's load, rows or save were re-read against this change:10682-form-error-clears-2.md(only the current load writes the banner; a superseded load neither raises nor clears it — still true),10631-master-detail-save-in-flight.md(the panel's grid is disabled while its own save is in flight — still true),6194-line-items-row-fetch-decline.md(thechildObjectdeclines andsave's guard — still true),6188,9050,9925,10163,8221,9333(each about another aspect of the panel — untouched). None describes what a failed or declined load for another parent leaves on screen, so no sentence is falsified and no dated note is needed; the new changeset declares this change.objectui#10712, measured, not widened
The same probe took the two sequences objectui#10712's release comment carries for its PR 2, at base and at this head:
p1answer landing afterp2committed: base draws['p1 line']underp2; head draws the placeholder (rowsHeldFormoved top1with the superseded rows, so they are refused), Save off. The current answer,p2's lines, is not kept: the run guard on the rows commit is still that PR's to add.p1that lands after the swap top2: its capturedloadissuesfindwithpo: 'p1'; base draws['p1 line edited']underp2; head draws the placeholder. The same partial mitigation, the same remaining item.objectui#10712 remains open; nothing here is scoped to it.
Round 3
The round-2 head
11bf099f11FAILED the contract review (5852845813) on R7 — a line typed into a grid drawn with no adapter, adopted by the first parent whose load later failed, then saved under it — and on four wording items. Every other round-2 claim was reproduced by the review.11bf099f11..ef16b93486, name-status / numstat:M packages/plugin-form/src/LineItemsPanel.tsx(+34 / −14),M packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx(+42 / −0),M .changeset/10740-line-items-held-for-another-parent.md(+11 / −10). Two commits:23f6bbc0closed R7 inonChangeand fixed the wording;ef16b93486gave theonChangeowner updater a type-safe spelling (see Deviations).onChangenow writes the rows' owner beside the rows when nothing is held yet — the seat's ruling permits theonChangeform as "strictly safer" because it covers any way a grid can be offered before a load settles (the adapter-less decline, and a superseded run releasingloading, objectui#10712's surface), not only the two the ruling enumerated.parentIdis narrowed in anif (parentId)block before the updater closes over it.47696a5819, green at head, and reddened by A6 (theonChangeowner adoption deleted). Table above.854e0922cc(prose and comment only; the round-3 contract review5853196779passed the code and failed the published text). One commit,M .changeset/10740-line-items-held-for-another-parent.md(+3 / −2),M packages/plugin-form/src/LineItemsPanel.tsx(+4 / −4). The changeset parenthetical now reads "(after a failed load a placeholder stands where the grid would be, under the failure's banner; after a refused-filter decline that filter's own notice stands there, as before; after a lost-adapter decline the placeholder stands alone)"; its frontmatter is byte-identical (md5 of the three frontmatter lines21fc281303c02040481bc4b368c13334before and after). TheonChangecomment's "The only grid offered before a load has settled…" became "The grid this was found on is the one drawn when the load declined for want of an adapter", which stays true against the superseded-release window (the empty grid drawn while a later read is pending). No code token moved: the commit's.tsxhunk has zero non-comment lines (git diff ef16b93486 854e0922cc -- packages/plugin-form/src/LineItemsPanel.tsx, filtered to changed lines that do not start with//, is empty); type-check and the dts build exit 0; and the threedist/artifacts hash-equal before and after (Gates above).type-check(bothtscprograms) andbuild(the dts plugin) all exit 0 at854e0922cc; the pin (9) is green there; the seven sibling pins plusrecordFormsErrorClears-10682(8 files / 102) and the light gates are green atef16b93486; the changeset gates,check:control-bytesandcheck:new-line-citationsare green at854e0922cc, listed above.Acceptance notes
catchfailure-adoption is unpinned since Round 3. A5 now moves no row: theonChangeadoption covers the same typed-line rows, and an empty grid left by a failed load is never saveable. Kept as the documented adopt-on-failure behavior, parallel to A2.onChangeadopts. Unpinned.Loading…,Save the record first to add line items., thechildObjecthint); only the malformed-filter state goes throughcreateSafeTranslation. Observation, not filed.Deviations
setRowsHeldFor((held) => held ?? parentId)(and equivalently theonChangeform) does not type-check: inonChange,parentIdisstring | null | undefined(RecordContextValue.recordIdisstring | null | undefined) with noloadguard above it to narrow it, and the setter's state isstring | undefined, so the updater'sheld ?? parentIdwidens to includenull— CI job 108556174339 red atsrc/LineItemsPanel.tsxerror TS2345on commit23f6bbc0, breaking the plugin-form build and the six gates behind it. Fixed inef16b93486by capturing the narrowed value:if (parentId) { const owner = parentId; setRowsHeldFor((held) => held ?? owner); }. Semantics unchanged (a grid is only offered with a parent bound). Thecatch's copy of the line type-checks becauseload's earlyif (!dataSource || !parentId) return;narrowsparentIdto a string before it.ablation-replaceresolves the repository from its cwd; run through the lock wrapper's default cwd it exits 2 at its own prerequisite (nothing written, nothing measured).disabledon the DOM node and clicked. React filters a click by the fiber'sdisabledprop, not the DOM property, so the click never reachedsaveand the row passed vacuously (A2 showed it). It was removed in11bf099f11rather than kept as a false pin; the pin header states what it would have pinned and why nothing can.11bf099f11was round 2;23f6bbc0andef16b93486are round 3), H3's closing and H2 item 2.Generated by Claude Code