Skip to content

docs(skills): the grid 2xl paragraph names objectui's own BreakpointColumnMap and reads all six keys - #10708

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-7599-breakpoint-attribution
Sep 27, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-7599-breakpoint-attribution

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #7599

Clause-②: no — prose in the published skills catalog; no declared surface moves

What changed

Five places in four files under skills/objectui/. Nothing else changed.

  1. rules/protocol.md, the intro sentence of "Rule: Layout Responsiveness": the list of object keys now includes 2xl.
  2. rules/protocol.md, the paragraph that opens with a bold 2xl sentence. It was rewritten whole.
  3. evals/protocol.json, eval id 3: expected_output was replaced. must_contain is unchanged (columns, node, xl, className), and every one of those tokens is still taught.
  4. guides/mobile.md, the cross-reference sentence after the KPI grid example. It was replaced.
  5. SKILL.md, "5. Layout as components": the parenthesis that points at rules/protocol.md was replaced. The file stays at 137 lines.
before after
intro key list xs / sm / md / lg / xl xs / sm / md / lg / xl / 2xl
owner of the six keys "The spec's BreakpointColumnMapSchema" BreakpointName in @object-ui/types is the key set of GridSchema.columns; BreakpointColumnMap in @object-ui/layout (responsive-grid) spells the same six
what grid reads "only the first five" all six, since objectui#7097
{xs:1, "2xl":6} on main grid grid-cols-1 gap-4 grid grid-cols-1 2xl:grid-cols-6 gap-4
{xs:1, xl:5} grid grid-cols-1 xl:grid-cols-5 gap-4 unchanged
advice "Stop at xl, or carry the widest step in className", unconditional kept only for an @object-ui/components release that predates objectui#7097, where 2xl is accepted and silently dropped (grid grid-cols-1 gap-4)
eval 3 expected_output three faults, including "the grid renderer reads only xs through xl"; rewrite stops at xl two faults (props envelope, cols); rewrite uses columns on the node with xs, md and 2xl; the pre-objectui#7097 qualifier
guides/mobile.md cross-reference "the 2xl entry the spec accepts and the renderer drops" "the six breakpoint keys the grid renderer reads, the 2xl step that an @object-ui/components release predating objectui#7097 accepts and silently drops"
SKILL.md section 5 parenthesis "has the keys the renderer reads and the one it drops" "has the six keys the renderer reads and the release that still drops 2xl"

Why the behaviour half changed

The triage (comment 5818990275) and boundary 1 in comment 5549222679 said to keep "2xl is accepted and then dropped" and change only the attribution, after re-measuring on main. I re-measured on base 1e946c96a, and that sentence is no longer true on main. After this card was filed, objectui#7097 (PR #8435) added a sixth read arm and the GRID_COLS_2XL class map to grid.tsx. Its pin, grid-breakpoint-columns-7097.test.tsx, passes on base (11/11). The pin covers both rows the paragraph quotes: { xs: 1, '2xl': 6 } gives grid grid-cols-1 2xl:grid-cols-6 gap-4, and { xs: 1, xl: 5 } gives grid grid-cols-1 xl:grid-cols-5 gap-4.

The old sentence is still true for readers on npm. The catalog is served from main by npx skills add objectstack-ai/objectui, so this paragraph goes live at squash. Readers, though, run @object-ui/components from npm, and the latest published version is 17.6.0. There grid still drops 2xl: the review measured {xs:1,"2xl":6} on the 17.6.0 tarball and got grid grid-cols-1 gap-4. So the paragraph states main's behaviour and keeps the old advice for a release that predates objectui#7097.

The bundle's two other cross-references now point at the same two-release reading: guides/mobile.md, and SKILL.md section 5. A bundle-wide grep in the round-2 review (2xl, then drops) found them.

Measurements

  • H1: which package owns BreakpointColumnMap.
    • On main, export interface BreakpointColumnMap is declared in packages/layout/src/ResponsiveGrid.tsx. @object-ui/layout's index publishes it through export * from './ResponsiveGrid'.
    • Under packages/types/src, with tests and comment lines excluded, 0 lines name BreakpointColumnMap. Control: 5 lines name BreakpointName under the same filter.
    • The published 17.6.0 tarballs name the same owner. @object-ui/layout's d.ts carries the type, at that version as a re-export from @objectstack/spec/ui. @object-ui/types's d.ts files name it 0 times. Control: 7 of its d.ts files name GridSchema.
  • H1: where BreakpointName is exported. It is declared once, as export type BreakpointName in packages/types/src/mobile.ts. The package index re-exports it once, as a member of an export type block.
    • Correction to round 1: I said "3 export lines name BreakpointName". That count used export.*BreakpointName over packages/types/src, and it included two lines that are not exports of the name. One is export type ResponsiveValue, which names BreakpointName without exporting it. The other is a comment in spec-ui-schema-reexports.test.ts.
  • Installed spec. @objectstack/spec is 17.4.0. Its dist has 0 files that contain BreakpointColumnMapSchema. Control: 56 files contain FieldSchema.
  • H2: what the grid registration declares.
    • inputs declares columns as type: 'number', described as "Number of columns on mobile devices". It also declares the flat smColumns to xlColumns inputs. There is no flat 2xl input.
    • The breakpoint-object form is declared by GridSchema.columns in @object-ui/types, which is now number | Partial of a Record over BreakpointName with number values. Its zod mirror is a z.partialRecord over the six names.
  • H3: the new sentence. grid does not import BreakpointColumnMap: its columns is keyed by BreakpointName. So the paragraph now names BreakpointName as the key set of GridSchema.columns, and BreakpointColumnMap as responsive-grid's spelling of the same six. The retired spec schema is not named anywhere.

Gates, run on head cfd9fed6d

gate exit verdict line
check:skills-paths 0 "check-skills-paths: OK (88/89 stated path(s) resolve across 20 guide file(s); 1 baselined)."
check:skill-eval-tokens --self-test 0 "OK check-skill-eval-tokens self-test: 29 cases pass ..."
check:skill-eval-tokens 0 "Red under the chosen oracle: 0 (0 beyond the baseline)." / "Every must_contain token is taught by its own skill bundle."
check-shell-escape-residue 0 "check-shell-escape-residue: OK (5/5 root(s) resolved ... skills: 16 file(s), 211 fence(s) ...)"
check:new-line-citations 0 "VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0"
check:control-bytes 0 "check-control-bytes: OK (scanned 8854 tracked text file(s); skipped 85 binary)."
check-changeset-presence 0 "No source or published contract of a released package changed in this range, so no changeset is owed." (4 files changed, 0 of them published package source)

Round 1 (head ae9ef099d) also ran these, all exit 0: check-changeset-claims (report-only; the 4 pending changesets it lists as naming protocol.md are about other rules), check-changeset-no-major, check:doc-types, check-prompt-component-keys, check:doc-fences, check:doc-example-ids and docs:check-links. None of those five doc gates reaches skills/. check-governed-queue-guard --test exits 3, "GOVERNED", which is expected.

  • Tests that name a skills/ path.
    • Selection: git grep -l -E over *.test.ts and *.test.tsx, with the pattern rules/protocol\.md|skills/objectui|'skills'. It selects 17 files. The narrower pattern rules/protocol\.md|skills/objectui selects 15. The two extra files are apps/console/src/__tests__/preview-samples-spec-valid.test.ts and scripts/__tests__/layered-read-declared-path-4016.test.ts.
    • Of the 17, 10 reference a skills path outside a comment. The other 7 mention it only in comments.
    • pnpm exec vitest run --maxWorkers=2 over all 17, on cfd9fed6d, ran under the verify lock: "Test Files 17 passed (17)" and "Tests 429 passed (429)". The lock verdict was "command-exit 0".
    • Both tests that read evals/ are in the set: check-skill-eval-tokens.test.ts and skill-guide-provider-envelope.test.tsx.
  • No package was touched, so no build, type-check or package test is owed.
  • Skill Example Check was not run locally. This narrowing is declared on purpose. The gate needs a 30-package build first. The evidence that its verdict cannot change:
    • Under skills/, on base 1e946c96a, on e2d985c56 and on cfd9fed6d: 422 fence-delimiter lines (211 fences), 88 lines containing the os: comment marker, and 88 exact os:check marker lines.
    • In each file this branch edits that has fences, the fence count and a sha256 over every fence body plus the line directly above each fence are identical on all three commits:
      • protocol.md: 13 fences, 38e8cf0aa790dff6.
      • guides/mobile.md: 8 fences, 3f0dd31cbbb4ee14.
      • SKILL.md: 1 fence, 61537070dd9f058c.
    • As a control, editing one fence in protocol.md changes its hash to 4d017f0b19a3a643.
    • The round-2 and round-3 hunks contain 0 fence-delimiter lines and 0 HTML-comment lines.
    • CI's "Skill Example Check" concluded success on base 1e946c96a, on ae9ef099d and on e2d985c56.
  • Doc Snippet Type Check is not reached by this diff. Its surface is content/docs, apps/*/docs, package READMEs, root docs/ and the root README.md. It does not include skills/.

Skills catalog readings

  • skills/objectui/rules/protocol.md: 361 lines before, 364 after (15992 bytes before, 16227 after). The +3 lines come from the review's wording: two readings of 2xl, one for main and one for pre-objectui#7097 releases.
  • skills/objectui/evals/protocol.json: 55 lines before, 55 after (2892 bytes before, 3028 after). One string value was replaced.
  • skills/objectui/guides/mobile.md: 168 lines before, 168 after (5057 bytes before, 5102 after). The rewrapped sentence still takes five lines.
  • skills/objectui/SKILL.md, the only published SKILL.md: 137 lines before, 137 after (7178 bytes before, 7200 after). So the sum over all SKILL.md files is 137 before and 137 after.
  • objectui has no line ratchet on skills/**.

Acceptance notes

  • The docblock of grid-breakpoint-columns-7097.test.tsx calls the drop that existed before objectui#7097 "the declared-but-not-read shape skills/objectui/rules/protocol.md warns authors about". That describes the history of the bug. protocol.md still warns about the cols shape and the props shape, and now about the pre-objectui#7097 drop. No edit, because packages/ is out of scope.
  • Release timing. The catalog is served from main, so these texts are live as soon as the PR squashes. The change they describe, objectui#7097, is still an unreleased changeset: npm's latest @object-ui/components is 17.6.0, and there grid drops 2xl.
    • The paragraph therefore carries a qualifier for releases that predate objectui#7097.
    • Eval 3 expects that same two-release answer.
    • The guides/mobile.md and SKILL.md cross-references point at it.
    • The re-home of BreakpointColumnMap, objectui#7580, is also pending: in published 17.6.0, @object-ui/layout exposes the same name as a re-export from the spec.
  • responsive-grid is not touched.

Merge safety

origin/main f9d772b16 was fetched into a private ref. git merge-tree --write-tree against it exited 0 (tree 9d0b7ef3264). Since base, main has not changed any of the four files, so no merge commit was needed.

维护者速读(草稿)

  • 改了什么:对外发布的技能包 skills/objectui/ 里,四个文件共改了五处:
    • rules/protocol.md「Layout Responsiveness」那一节的开头一句,断点键列表补上 2xl;
    • 同一节讲 2xl 的那一段整段重写;
    • evals/protocol.json 第 3 条评测的期望答案;
    • guides/mobile.md 里指向该规则的那句交叉引用;
    • SKILL.md 第 5 节里指向该规则的那个括注。
  • 为什么改:原文有两处已经不对。
    • 第一,它说六个断点键归 spec 的 BreakpointColumnMapSchema 所有,可这个 schema 已经被 spec 退役了。现在 grid 的 columns 由 @object-ui/types 的 BreakpointName 定键,responsive-grid 用的 @object-ui/layout 的 BreakpointColumnMap 是同样的六个键。
    • 第二,它说 grid 会丢掉 2xl、作者要「停在 xl」。可 main 上 objectui#7097 已经让 grid 正确渲染 2xl。
    • 第 3 条评测和另外两处交叉引用还在复述旧说法,所以一起改。
  • 风险与代价(含回滚):
    • 这是纯文档和评测文本的改动:不动代码,不发包,不需要 changeset。
    • 有一个时间差:技能包是从 main 分发的,合并当下就生效;但 npm 上最新的 17.6.0 里 grid 仍然会丢掉 2xl。所以这一段同时写了两种情况:main 或新版本读六个键;objectui#7097 之前的版本仍要停在 xl,或者把最宽的那一档写进 className。
    • protocol.md 净增 3 行,其余三个文件行数不变。
    • 回滚办法:revert 这三个 commit。
  • 席位意见:建议批准。三轮契约审查:第 1、2 轮只在措辞上 FAIL,第 3 轮在 cfd9fed6d 上 PASS(记录已贴在本 PR)。改动只涉及技能包文案,不动代码、不发包,4 个文件都在 skills/objectui/ 下,和其他开着的 PR 没有文件重叠。合并后从 main 分发的读者立即看到新说法;还在用 npm 17.6.0 的读者,段内限定语仍然给出正确做法。
  • 你要做的:审阅这五处。同意的话,请留一条 APPROVED review:这是受管面 skills/**,需要 os-zhuang 或 hotlong 批准,之后由席位落地。

Generated by Claude Code

…olumnMap and reads all six keys

`skills/objectui/rules/protocol.md` attributed the six-key breakpoint
vocabulary to the spec's `BreakpointColumnMapSchema`. objectstack#11027
retired that schema (released in @objectstack/spec 17.3.0; this repo
installs 17.4.0), and objectui#7580 re-homed the vocabulary as
`BreakpointColumnMap` in `@object-ui/layout`, so the paragraph now names
that owner instead.

The same paragraph said `grid` drops `2xl`. Re-measured on main, that no
longer holds: objectui#7097 gave the renderer a sixth read arm and class
map, and `{xs:1, "2xl":6}` now renders `grid grid-cols-1 2xl:grid-cols-6
gap-4`. The `{xs:1, xl:5}` row is unchanged. The paragraph now says the
renderer reads all six keys, and the obsolete "stop at `xl`" advice is gone.

Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 5 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/4795-bindable-text-keys.md

  • names skills/objectui/rules/protocol.md → skills/objectui/rules/protocol.md — edited by this change

    Published authoring guidance updated to match: skills/objectui/rules/protocol.md (new "Bindable Text Keys" rule), plus the page-builder, schema-expressions and data-integration guides, which taught the now-retired "never evaluated" statement and its host-pre-resolution workaround.

.changeset/5942-retire-mobile-component-config.md

  • names skills/objectui/guides/mobile.md → skills/objectui/guides/mobile.md — edited by this change

    Removed outright rather than kept as a ?: never tombstone, on this package's own retire-vs-remove discriminator, in the form objectui#7678 amended it to. That rule is cited here and not restated: it is stated once, and a second copy carried in a release note could only drift out of agreement with it. Measured against it the route is removal — the whole interface goes, so there is no surviving object to hang a never key on, and no documentation ever described it (skills/objectui/guides/mobile.md teaches the hooks, never this type). Same zero-pull, no-successor shape as MobileOverrides (objectui#4919) and AccordionItem.icon / ToggleGroupItem.icon.

.changeset/6357-basechema-bind-declaration.md

  • names skills/objectui/rules/protocol.md → skills/objectui/rules/protocol.md — edited by this change

    bind was read by ten production sites and declared by no schema shape. It resolved as any through BaseSchema's index signature and rode .passthrough() on the validator, while three separate documents taught it as an authorable key of every node: this repo's own AGENTS.md §4 ("Every node in the UI tree follows this shape (@object-ui/types)"), the published agent-facing skills/objectui/rules/protocol.md ("Every UI component node MUST follow this shape"), and content/docs/fields/grid.mdx. So the agent-facing protocol told authors to write a key the published types did not know existed.

.changeset/6575-data-table-bind-diagnostic.md

  • names skills/objectui/rules/protocol.md → skills/objectui/rules/protocol.md — edited by this change

    The platform was already paying for this in teaching rather than in diagnostics: skills/objectui/rules/protocol.md documents the pothole verbatim and a pin test locks the behaviour. The warning now also reaches the console, where the author who did not read the docs is standing:

.changeset/6665-data-table-non-array-data-diagnostic.md

  • names skills/objectui/rules/protocol.md → skills/objectui/rules/protocol.md — edited by this change

    The spelling that opened the card is a ${...} expression string, and it is a defect rather than a design because the SAME expression is evaluated one key over. Re-measured on merge-base 5967be095 through the real SchemaRenderer (the table was previously quoted from skills/objectui/rules/protocol.md as a measurement on f1c27f037 and had not been re-run); all four legs reproduced, and they are now pinned as tests rather than prose:

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with f9d772b16 (merge-base with origin/main): 4 file(s) changed outside .changeset/, read against 1540 pending declaration(s) that publish a body (2126 pending in total). · run

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ae9ef099d5cce7c679f94357664a81a4394d4120

① Derived judgments

  1. How the catalog is consumed, and why "installs from main" is the defect, not the defence. The skill ships by npx skills add objectstack-ai/objectui (README, content/docs/guide/agent-skills.md, skills/objectui/README.md): the skills CLI fetches the GitHub repo at its default branch. No npm tarball, no release tag, no version field that moves (SKILL.md metadata.version: "2.0" was set once, in skills(objectui): optimization flight — contributor-only content moved out, duplicates merged into rules/, router first, three evals corrected (net −20,344 tokens, −31.8%) #7370). So the paragraph goes live for every reader at squash time, while the reader's @object-ui/components comes from npm at 17.6.0 (published 2026-08-24; fix(components): ui:grid renders the 2xl breakpoint its columns map accepts #8435 merged 2026-09-07; .changeset/7097-grid-2xl-breakpoint.md is still pending on main). Measured on the 17.6.0 tarball: 0 dist files contain 2xl:grid-cols, no columns['2xl'] read arm; {xs:1,"2xl":6} renders grid grid-cols-1 gap-4. A reader who follows "the grid renderer reads all six" today gets exactly the silent drop the old paragraph existed to prevent. The body's "the paragraph describes the code it will ship with" is wrong in its premise: the catalog does not ship with a release, it is served from main now. The paragraph needs a release-neutral qualifier and must keep the fallback advice for pre-columns: { "2xl": N } parses and is then silently dropped — the spec accepts six breakpoints, the grid renderer reads five #7097 installs. Replacement text in the verdict (issue-anchored, no version literal: objectui#8924's lesson is that nothing reads a version literal in prose).

  2. Attribution is correct on the package and misleading on the symbol. BreakpointColumnMap is declared in packages/layout/src/ResponsiveGrid.tsx on main and re-exported by @object-ui/layout's index; @object-ui/types exports it nowhere (0 export lines on main, 0 d.ts files in 17.6.0; control 7 d.ts files name GridSchema). The triage's "(@object-ui/types)" was wrong and the dev fixed it correctly. In 17.6.0 @object-ui/layout's d.ts only re-exports the name from @objectstack/spec/ui (pre-existing, see ③). But grid's own columns is Partial of Record over BreakpointName (packages/types/src/layout.ts, BreakpointName in mobile.ts, zod mirror z.partialRecord over the six names), and grid never imports BreakpointColumnMap, which the card itself states. Telling a grid author the six keys are "declared as BreakpointColumnMap in @object-ui/layout" sends them to responsive-grid's type, not to the one that types grid.columns: the objectstack#4115 shape the card was filed to remove. True as a statement about the vocabulary, wrong as a pointer. The replacement names BreakpointName / GridSchema.columns first and BreakpointColumnMap as the same six.

  3. The fence deviation was right in direction and incomplete in execution. On main grid.tsx has GRID_COLS_2XL and the columns['2xl'] arm; I ran grid-breakpoint-columns-7097.test.tsx on current main 383a34f52 in a scratch worktree: 1 file, 11 of 11 passed (worktree removed). The claim comment conditioned keeping the sentence on re-measurement, so leaving "accepted and then dropped" would have been the worse defect. The deviation is declared in the body. Two things in the same published bundle were left contradicting the new paragraph:

    • The intro sentence of the same Rule still reads "keyed xs / sm / md / lg / xl". Before the PR that matched "reads only the first five"; now it contradicts "reads all six" two paragraphs later. "Incomplete but not wrong" does not hold once the section itself says six.
    • skills/objectui/evals/protocol.json id 3 (authored with this paragraph in skills(objectui): optimization flight — contributor-only content moved out, duplicates merged into rules/, router first, three evals corrected (net −20,344 tokens, −31.8%) #7370) still grades the correct answer as "2xl parses but the grid renderer reads only xs through xl, so a 2xl entry emits no class. Rewrites it as columns on the node, stopping at xl, and offers className". The bundle's own grader now fails an agent that has read the bundle's own rule. check-skill-eval-tokens states it does not judge expected_output, so Skill Eval Token Check is green on a self-contradiction. The eval must move with the rule, in this PR.
  4. Gates and changeset. Reaching skills/**: check:skills-paths (Skill Guide Path Check), check:skill-eval-tokens (Skill Eval Token Check), check-shell-escape-residue (Shell Escape Residue Scan) and Skill Example Check (skill-examples.yml, deliberately no path filter, builds dist and compiles marked fences). All four concluded success on the head. The dev's "cannot move it" argument holds: the hunk has no fence or marker line; protocol.md has 26 fence-delimiter lines (13 fences) and 12 markers on both base and head; 88 markers under skills/ on both. check-changeset-presence guards pkg/src/** of the fixed group only; skills/ is not a workspace package (pnpm-workspace.yaml: packages, examples, apps, docs) and has no versioning of its own, so "no changeset owed" is right. No line ratchet exists on skills/** (the ratchets in check-skill-examples.mjs are marked-fence floors; check-skills-paths ratchets its exemption baseline); the card's and triage's "line ratchet" names no gate on this tree, the body's statement holds.

  5. PR body, sentence by sentence. Line 1 Fixes #7599 is the only closing keyword. No tag-shaped angle brackets (the one > is the arrow in "report-only -> exit 0"). No model identifier. Verified true: base 1e946c96a is the merge-base; 361 → 359 lines, 15992 → 15881 bytes; SKILL.md 137 both; spec 17.4.0 in the lockfile, 0 dist files with BreakpointColumnMapSchema and 56 with FieldSchema; flat inputs stop at xlColumns; {xs:1, xl:5} row verbatim; main has not touched protocol.md, grid.tsx, ResponsiveGrid.tsx or packages/types/src since base. Not reproduced, immaterial: "17 test files" (I count 15 by git grep -l over test globs), "3 export lines name BreakpointName" (I count 2 plus the export type block in index.ts). Wrong conclusion: the "Release timing" bullet (judgment 1). Per template, not defects: empty "席位意见" and "(草稿)" (os-dev role file: seat opinion left blank), session-URL footer. objectui#7097 is not retrievable via REST (404, as is pnpm test:integration cannot run — it filters on a vitest project named ui, which does not exist #7096); the cite is corroborated by PR fix(components): ui:grid renders the 2xl breakpoint its columns map accepts #8435 ("Fixes columns: { "2xl": N } parses and is then silently dropped — the spec accepts six breakpoints, the grid renderer reads five #7097", merged 3cab5703b, grid.tsx +33, the pin test, the 7097 changeset on main).

  6. CI and merge. 39 check runs on the head: 36 success, 3 skipped (coverage matrix, dependabot), 0 failed, none pending; combined status success. Governed Surface Queue Guard success is the report-only pull_request leg (exit 0 by design). git merge-tree --write-tree against current origin/main 383a34f52: exit 0, clean.

② Semver level

None. Prose only on skills/**; no package source, no published contract, no changeset owed and none present; the catalog carries no version.

③ Boundary flags

  • Governed surface skills-catalog (skills/**): stays draft until a latest-decisive APPROVED review from a GOVERNED_APPROVERS account (os-zhuang / hotlong). This record is not that approval, and a PASS here would not replace it. It is also a dispatched PR (Claim comment), so the dispatch seat lands it after approval, not the dev.
  • Deviation from 5549222679 boundary 1 (attribution only): declared, right in direction, and the reason the eval and intro fixes are now owed inside the same file surface (skills/objectui/, same governed approval).
  • Triage 5818990275 mis-attributed the owner package (@object-ui/types); the dev's @object-ui/layout is the measured owner.
  • Release-window exposure: the catalog is served from main; the installed base is 17.6.0 where grid drops 2xl.
  • Pre-existing, out of scope: @object-ui/layout@17.6.0's d.ts re-exports BreakpointColumnMap from @objectstack/spec/ui while depending on ^17.0.0, which resolves to 17.3.0 or later where that export is gone. The pending 7580 changeset is the fix; worth a card if none exists.
  • Pin run: the locked attempt hit queue-timeout (exit 99, NOT MEASURED) behind another seat's full packages/components/ run; the lock ledger shows the dev's own locked run of the same file with rc=0. I ran the single file unlocked (not a heavy verification under the AGENTS.md lock rule): 11 of 11. Scratch worktree removed.

Implemented-by: claude/issue-7599-breakpoint-attribution
Reviewed-by: session_014mXUNuFomfj24w7s1pZzhN

VERDICT: FAIL

Wording only; three replacements, all inside skills/objectui/.

  1. skills/objectui/rules/protocol.md, intro sentence of "Rule: Layout Responsiveness", replace the key list:
    breakpoint object keyed xs / sm / md / lg / xl / 2xl (GridSchema in packages/types/src/layout.ts):

  2. skills/objectui/rules/protocol.md, the rewritten paragraph, replace whole:
    2xl is the sixth key. Six keys — xs … xl plus 2xl — are objectui's own breakpoint vocabulary: BreakpointName in @object-ui/types is the key set of GridSchema.columns, and BreakpointColumnMap in @object-ui/layout (responsive-grid) spells the same six. Since objectui#7097 the grid renderer reads all six. Measured on main: {xs:1, xl:5} → grid grid-cols-1 xl:grid-cols-5 gap-4; {xs:1, "2xl":6} → grid grid-cols-1 2xl:grid-cols-6 gap-4. On an @object-ui/components release that predates objectui#7097 the 2xl step is accepted and silently dropped (grid grid-cols-1 gap-4): there, stop at xl or carry the widest step in className.

  3. skills/objectui/evals/protocol.json, eval id 3, replace expected_output (keep must_contain columns, node, xl, className; all still taught):
    Names the two faults that cause the flat grid: the keys are under props and so are never read; the key is columns, not cols, which no schema or renderer declares. Rewrites it as columns on the node with xs, md and 2xl. Notes that 2xl is the sixth breakpoint key and is read by the grid renderer since objectui#7097; on an earlier @object-ui/components release the 2xl step is accepted and silently dropped, so there stop at xl or carry the widest step in className.

Neither replacement adds a fence, a marker, a path, a path:line citation or a version literal, so the four skills/** gates and the line-citation gate are not moved.


Generated by Claude Code

…097 releases, and the eval follows

Review round 2 on the same paragraph. The intro sentence of "Rule: Layout
Responsiveness" now lists `2xl` among the object keys. The `2xl` paragraph
names `BreakpointName` in `@object-ui/types` as the key set of
`GridSchema.columns`, and `BreakpointColumnMap` in `@object-ui/layout` as
`responsive-grid`'s spelling of the same six. It also keeps the old advice
for readers on an `@object-ui/components` release that predates
objectui#7097: the catalog is served from main, while npm 17.6.0 still
drops the `2xl` step.

Eval 3 in `skills/objectui/evals/protocol.json` stops expecting "the grid
renderer reads only xs through xl" and expects the same two-release
reading; its `must_contain` tokens are unchanged.

Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e2d985c56dcde06bc62b9d4ad3e79e681fe6d55a

① Derived judgments

  1. The three replacements are in the tree verbatim. Mechanical comparison against the round-1 text: the intro key list (protocol.md:313), the whole 2xl paragraph (protocol.md:330-339) and eval id 3's expected_output (protocol.json:40) are character-identical to the record's text; must_contain (columns, node, xl, className), must_not_contain ("cols":) and the prompt are unchanged from base. ae9ef099d..e2d985c56 is 2 files, +11/-6; against base 1e946c96a it is 2 files, +10/-7. Every new sentence measured true on main f9d772b16: grid.tsx has GRID_COLS_2XL (line 60) and the schema.columns['2xl'] read arm (line 90), and does not import BreakpointColumnMap (one comment mention only); GridSchema.columns is number | Partial<Record<BreakpointName, number>> (layout.ts:769), BreakpointName is declared once in mobile.ts:159, the zod mirror is z.partialRecord over the six names (layout.zod.ts:393); BreakpointColumnMap is declared in packages/layout/src/ResponsiveGrid.tsx:53 with exactly the six members and reaches @object-ui/layout through export * from './ResponsiveGrid', registered as responsive-grid; the pin grid-breakpoint-columns-7097.test.tsx holds both quoted rows (lines 147-148, 168-169). Re-measured on the @object-ui/components@17.6.0 tarball (npm latest, published 2026-08-24): 0 dist files contain 2xl:grid-cols, 0 contain a 2xl read arm or GRID_COLS_2XL; control 2 files contain xl:grid-cols. The 7097 and 7580 changesets are both still pending on main.

  2. Two sentences elsewhere in the same published bundle still state the old rule. The task's 2xl grep over skills/objectui/ has three files; mobile.md:26 is the plain breakpoint table (fine), but skills/objectui/guides/mobile.md:99-103 still sends the reader to the rule as "the 2xl entry the spec accepts and the renderer drops": unconditional drop, and the spec attribution the card was filed to remove. Widening the grep to drops finds skills/objectui/SKILL.md:105-106, the entry file: "rules/protocol.md has the keys the renderer reads and the one it drops". Both are cross-references that summarise the rewritten paragraph with its pre-PR claim; round 1's own reasoning ("incomplete but not wrong does not hold once the section itself says six") applies to them exactly. Neither was in round 1's list, so the dev is not at fault, but the bundle is still self-contradictory and both fixes are wording on the same governed file surface. Replacement text in the verdict. No other hit: only the first five, xs through xl, stopping at xl, BreakpointColumnMapSchema, first five, reads only return nothing; stop at xl returns only the new eval qualifier; the evals contain no contradicting text.

  3. Skills gates hold. protocol.md has 26 fence-delimiter lines (13 fences) and 12 markers on base, ae9ef099d and head; <!-- os: markers under skills/ are 88 on all three; the hunk (and the whole base..head diff) touches 0 fence and 0 HTML-comment lines. On the head: Skill Guide Path Check success, Skill Eval Token Check success, Skill Example Check in progress (success on base 1e946c96a and on ae9ef099d; the two replacements below add no fence, marker, path, path:line citation or version literal, and keep both existing rules/protocol.md links, so none of the three gates or the Line Citation Gate is moved).

  4. Body, sentence by sentence. True: the "What changed" table (every "before" cell matches the base paragraph, every "after" cell the head); "Why the behaviour half changed" (pin rows, PR fix(components): ui:grid renders the 2xl breakpoint its columns map accepts #8435, 17.6.0 measurement, catalog served from main); the recounts (17 files by the wider pattern, 15 by the narrower, the two extras are preview-samples-spec-valid.test.ts and layered-read-declared-path-4016.test.ts; 10 non-comment vs 7 comment-only; export.*BreakpointName is exactly the 3 lines named, ResponsiveValue and the test comment being the two non-exports; index.ts:669 is inside an export type { block; 5 non-comment non-test lines name BreakpointName, 0 name BreakpointColumnMap); the catalog readings (361 to 364 lines, 15992 to 16227 bytes; protocol.json 55/55 lines, 2892 to 3028 bytes; one SKILL.md, 137 both); spec 17.4.0 in the lockfile with 0/56 on its tarball; @object-ui/types@17.6.0 d.ts: 0 name BreakpointColumnMap, 7 name GridSchema; @object-ui/layout@17.6.0 re-exports BreakpointColumnMap from @objectstack/spec/ui with @objectstack/spec: ^17.0.0; the Doc Snippet surface claim matches check-doc-snippet-types.mjs's own docblock; the Release-timing bullet is now correct. The Chinese summary is true and says correctly that skills/** is governed, needs a GOVERNED_APPROVERS (os-zhuang / hotlong) APPROVED review, and that the seat lands afterwards (AGENTS.md line 531, check-governed-queue-guard.mjs:332). Line 1 Fixes #7599 is the only closing keyword; the only > is the -> arrow on line 51; no model identifier. Stale, not wrong: "Nothing else changed" / "改了三处" / "revert 这两个 commit" and the merge-tree SHA 383a34f52 will all need refreshing once the two replacements land (main is now f9d772b16).

  5. CI and merge. 39 check runs on the head: 23 success, 13 in progress (test shards, Type Check, Lint, Skill Example Check, Doc Snippet Type Check, Spec Main Shape Gate), 3 skipped, 0 failed. git merge-tree --write-tree against current main f9d772b16 (6 commits past base): exit 0, clean; none of those commits touch protocol.md, protocol.json, grid.tsx or ResponsiveGrid.tsx.

② Semver level

None. Prose only on skills/**; no package source, no published contract, no changeset owed and none present; the catalog carries no version.

③ Boundary flags

  • Governed surface skills-catalog (skills/**): lands only after a latest-decisive APPROVED review from a GOVERNED_APPROVERS account (os-zhuang / hotlong). This record is not that approval and a PASS here would not replace it; the dispatch seat lands after it.
  • Round 1's own sentence "BreakpointName in @object-ui/types is the key set of GridSchema.columns" is true on main (objectui#8505 narrowing) but not on published 17.6.0, where GridSchema.columns is number | Record<string, number> and BreakpointName is a re-export of @objectstack/spec/ui. Same release-window class as the 7580 note already in the body; the pointer still resolves there and the type is wider, not narrower, so no reader is harmed. Recorded, not a FAIL item.
  • The two residuals below were outside round 1's stated list; they are found by the round-2 mandate's bundle-wide 2xl grep, not by anything the dev omitted from the record.
  • Scratch tarballs downloaded to the scratchpad were removed; no checkout, no edits, no GitHub writes.

Implemented-by: claude/issue-7599-breakpoint-attribution
Reviewed-by: session_014mXUNuFomfj24w7s1pZzhN

VERDICT: FAIL

Wording only; two replacements, both inside skills/objectui/, plus the body refresh.

  1. skills/objectui/guides/mobile.md, the sentence after the KPI grid fence (lines 99-103), replace the sentence that begins "The breakpoint keys the grid renderer actually reads" with:
    The six breakpoint keys the grid renderer reads, the 2xl step that an @object-ui/components release predating objectui#7097 accepts and silently drops, and the cols / props spellings that render nothing are in rules/protocol.md under "Rule: Layout Responsiveness".

  2. skills/objectui/SKILL.md, lines 105-106 under "5. Layout as components", replace the parenthesis so the two lines read (line count stays 137):
    node as columns — a number, or a breakpoint object (rules/protocol.md
    has the six keys the renderer reads and the release that still drops 2xl).

  3. Body: "What changed" becomes five places in four files (add guides/mobile.md and SKILL.md); "Skills catalog readings" gains mobile.md 168 to 169 lines with bytes; the Chinese summary's "改了三处" and "revert 这两个 commit" follow the new count; re-run the gate table on the new head and refresh the merge-tree SHA to current main.

Neither replacement adds a fence, a marker, a path, a path:line citation or a version literal, and both keep the existing rules/protocol.md link, so the four skills/** gates and the line-citation gate are not moved.


Generated by Claude Code

…, not the old drop

Review round 3. Two cross-references in the published bundle still stated
the rule this branch retired. `guides/mobile.md` said the spec accepts
`2xl` and the renderer drops it. SKILL.md, section 5, said protocol.md
has the keys the renderer reads "and the one it drops". Both now point at
the six keys the `grid` renderer reads and at the `@object-ui/components`
release predating objectui#7097 that still drops `2xl`.

Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cfd9fed6db8ef89d30be374bafb27b8dd86842a5

① Derived judgments

  1. The round-3 delta is exactly the two replacements, verbatim, and nothing else. e2d985c56..cfd9fed6d is one commit, 2 files, +6/-6: SKILL.md +1/-1, guides/mobile.md +5/-5. Mechanical comparison against round 2's record: mobile.md lines 99-103, line-joined from "The six" onward, are character-identical to replacement 1; SKILL.md lines 105 and 106 are character-identical to replacement 2. SKILL.md line 105 is byte-identical on base 1e946c96a, e2d985c56 and head; the file is 137 lines on all three. mobile.md is 168 lines on all three (the sentence still spans lines 99-103), so the dev's 168 is right and round 2's expected 169 was wrong. Whole PR against merge-base 1e946c96a: 4 files, +16/-13, all under skills/objectui/; 3 commits.

  2. Every sentence of the two new texts is true as scoped. On current main a695f505f: packages/components/src/renderers/layout/grid.tsx has GRID_COLS_2XL (line 60) and the 2xl read arm (lines 81-89), so "the six breakpoint keys the grid renderer reads" holds; protocol.md "Rule: Layout Responsiveness" (head lines 311-347) carries the six-key list, the pre-objectui#7097 release qualifier, and both cols and props DO NOTs, so both cross-references resolve to text that says what they promise, and both keep their existing rules/protocol.md link. On published @object-ui/components@17.6.0 (npm latest, re-fetched into scratch and removed): 0 dist files contain 2xl:grid-cols, 0 contain GRID_COLS_2XL or a 2xl read arm; control 2 files contain xl:grid-cols. So "a release predating objectui#7097 accepts and silently drops" and "the release that still drops 2xl" hold. .changeset/7097-grid-2xl-breakpoint.md and 7580-responsive-vocabulary-rehome.md are both still pending on main; main has 0 commits since base touching the four files, grid.tsx, ResponsiveGrid.tsx, layout.ts or mobile.ts.

  3. Bundle-wide sweep, re-run on head. Over skills/: 2xl hits only the new texts, the eval, the mobile.md:26 breakpoint table and the rewritten rule; drops hits the two new texts, mobile.md:168 (network drops) and page-builder.md:243 (ObjectGrid drops an unnamed field); dropped hits the new eval and rule plus unrelated props/cols/expression sentences; stop at xl hits only the eval qualifier; only the first five, xs through xl, BreakpointColumnMapSchema, the spec accepts return nothing. No sentence under skills/ states the old unconditional drop or the spec attribution. Outside skills/ (README files, content/docs, .changeset/*.md, AGENTS.md, docs/): the only 2xl-plus-drop hits are .changeset/7097-grid-2xl-breakpoint.md lines 21-23, which describe the fixed drop as history and are correct; AGENTS.md:101 teaches columns on the node with no key list or drop claim; content/docs has no sentence on this rule. BreakpointColumnMapSchema outside skills/: the 7580 changeset and mobile.ts:126 both name it as retired (correct); packages/layout/src/ResponsiveGrid.tsx:114-115 docblock still says the component "consumes @objectstack/spec BreakpointColumnMapSchema", the spec attribution in a published package's source, outside this PR's surface (packages/, which the claim forbids): follow-up, not this PR. Pre-existing and unrelated to the sweep phrases: content/docs/api/schema-reference.md:226 types columns as number or a Record over string keys, the 17.6.0 shape rather than main's BreakpointName partial record; follow-up.

  4. Fence and marker counts, re-measured. Under skills/ on base, e2d985c56 and head: 422 fence-delimiter lines (211 fences), 88 lines with the os: comment marker, 88 exact os:check lines. Per file on all three commits: protocol.md 13 fences / 12 HTML-comment lines, mobile.md 8 / 2, SKILL.md 1 / 1. Every fence body plus the line directly above it is byte-identical across the three commits in all three files (direct comparison; I did not reproduce the dev's 16-hex literals because the hashing recipe is not fully specified, but the fact the literals stand for holds). The round-3 hunk has 0 fence-delimiter and 0 HTML-comment lines. No test pins the replaced phrases (the one it drops, the renderer drops, spec accepts and, renderer actually reads have 0 hits outside skills/ in any test against these files); doc-version-claims.test.ts anchors only the React/TypeScript claims in SKILL.md; the new texts carry no version literal. Pending changeset 5942 still holds ("mobile.md teaches the hooks, never this type": 0 mentions of MobileComponentConfig on head).

  5. Body pr-body-10708-r3.md, changed lines sentence by sentence against head. True: "Five places in four files" (protocol.md 2, protocol.json 1, mobile.md 1, SKILL.md 1); items 4 and 5 and "stays at 137 lines"; both new table rows quote base and head verbatim once line-joined (the mobile.md "after" cell lowercases the sentence-initial "The"; quotation case only); gate table head cfd9fed6d, and the CI equivalents on that head (Skill Guide Path Check, Skill Eval Token Check, Shell Escape Residue Scan, Line Citation Gate, Control Byte Scan, Changeset Declaration) all success; "(4 files changed, 0 of them published package source)"; the 422/211/88/88 counts and per-file fence counts 13/8/1; "0 fence-delimiter lines and 0 HTML-comment lines" for both hunks; Skill Example Check success on base, ae9ef099d and e2d985c56 (now also success on head: stale, not wrong); catalog readings 361 to 364 / 15992 to 16227, 55 / 2892 to 3028, 168 to 168 / 5057 to 5102, 137 to 137 / 7178 to 7200, all re-measured exactly; "latest published version is 17.6.0" (dist-tag latest 17.6.0, registry modified 2026-08-24); the four Release-timing sub-bullets; merge safety: git merge-tree --write-tree f9d772b16 refs/review/pr-10708 gives tree 9d0b7ef3264078fb… exactly as quoted and exit 0. Chinese summary: "四个文件共改了五处" and the five sub-items, "protocol.md 净增 3 行,其余三个文件行数不变", "审阅这五处" all true; "revert 这三个 commit" matches the PR's 3 commits. The 17-test claim was not re-run here (judged not needed: prose-only rewrap, fence bodies unchanged, gate equivalents green on head, test shards in CI); the verify-lock ledger corroborates a locked run from /home/user/objectui-issue-7599 with the 17-file label at 2026-09-25T20:47:33Z, after the commit at 20:42:04Z, rc=0, though the ledger records no SHA. One imprecision, not a landing blocker: line 33 "A bundle-wide 2xl grep in the round-2 review found them" is only half right, since SKILL.md:106 contained no 2xl before this commit and round 2 found it by widening to drops; if the seat edits the body at landing, "A bundle-wide grep in the round-2 review (2xl, then drops) found them." is exact. Stale, not wrong: main is now a695f505f (one commit past f9d772b16, objectui#8261 i18n, touching none of the four files); merge-tree against it is still clean (tree 68467c45e…, exit 0). "席位意见" is empty by design. Line 1 Fixes #7599 is the only closing keyword; the only > is the -> arrow on line 57; no model identifier. The live PR body is still the round-1 text ("One paragraph in …"), as the mandate anticipates.

② Semver level

None. Prose only on skills/**: the PR's 4 files are all under skills/objectui/; no package source, no published contract, no changeset owed and none present; the catalog carries no version.

③ Boundary flags

  • CI on head cfd9fed6d, at the last poll: 39 check runs, 29 success, 3 skipped (coverage matrix, coverage, dependabot), 7 in progress (Test shards 2, 3, 4, 5, 7 of 8; Type Check; Spec Main Shape Gate), 0 failed; combined status success. All six skills/**-reaching gates concluded success. The in-progress shards are the only unfinished evidence for the 17-test claim.
  • git merge-tree --write-tree against current main a695f505f: exit 0, clean.
  • Open PRs (13): none touches any of the 4 files. #10574 (guides/project-setup.md) and #8941 (guides/plugin-development.md) touch other skills/objectui/guides/ files, no overlap.
  • Governed surface skills-catalog (skills/**): GOVERNED_APPROVERS is ['os-zhuang', 'hotlong'] (check-governed-queue-guard.mjs:332). The PR has 0 reviews of any state and is draft; there is no APPROVED review yet. This record is not that approval; landing waits on it, after which the dispatch seat lands with the replacement body.
  • Follow-ups outside this PR's surface, recorded not owed: packages/layout/src/ResponsiveGrid.tsx:114-115 docblock spec attribution; content/docs/api/schema-reference.md:226 columns type spelled as the 17.6.0 shape.
  • No worktree was created; scratch tarball removed; no checkout, no edits, no GitHub writes.

Implemented-by: claude/issue-7599-breakpoint-attribution
Reviewed-by: session_014mXUNuFomfj24w7s1pZzhN

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)

改了什么:对外发布的技能包 skills/objectui/ 里四个文件共五处:rules/protocol.md「Layout Responsiveness」一节的断点键列表补上 2xl,同节讲 2xl 的那一段整段重写;evals/protocol.json 第 3 条评测的期望答案;guides/mobile.md 与 SKILL.md 里指向该规则的两处交叉引用。净 +16 / −13 行,protocol.md 净增 3 行,其余三个文件行数不变。

为什么改:原文两处已不对。一是它把六个断点键归给 spec 的 BreakpointColumnMapSchema,而该 schema 已被 spec 退役,现在 grid 的 columns 由 @object-ui/types 的 BreakpointName 定键,@object-ui/layout 的 BreakpointColumnMap 是同样六个键;二是它说 grid 会丢掉 2xl、作者要停在 xl,而 main 上 objectui#7097 之后 grid 已正确渲染 2xl(实测 {xs:1, "2xl":6} → grid grid-cols-1 2xl:grid-cols-6 gap-4)。评测第 3 条与两处交叉引用复述旧说法,一并改。

风险与代价(含回滚):纯文档与评测文本,不动代码、不发包、不欠 changeset。有一个时间差:技能包从 main 分发,合并即生效,而 npm 最新 17.6.0 里 grid 仍丢 2xl,所以新段落同时写了两种情况(main 或新版本读六个键;objectui#7097 之前的版本仍停在 xl 或把最宽档写进 className)。回滚 = revert 这三个 commit。

席位意见:ui 席三轮契约复核,第 1、2 轮只在措辞上 FAIL,第 3 轮在 head cfd9fed6db 上 PASS(5839535106),head 未再移动;本席对照 diff 复读了草稿,五处与 diff 一一对应,Clause-②: no;CI 37 成功、3 跳过,mergeable_state: clean。建议批准。

你要做的:一条 APPROVED review(os-zhuang 或 hotlong 账号,已请审)。批准后由持卡席位(domain:ui#2)按其 01:31Z 记录的落地步骤 ready + auto-merge,不需要您合并。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 27, 2026 06:05
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit f308a65 Sep 27, 2026
42 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7599-breakpoint-attribution branch September 27, 2026 06:19
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…nes it holds for another parent record (objectui#10740) (objectstack-ai#10751)

Fixes objectstack-ai#10740
Clause-②: no

A component's internal state and its Save guard; no declared surface
moves. `@object-ui/plugin-form` publishes `.` only, and no export, type,
prop or registration changes. Dispatched by the `domain:ui` seat 2,
session `https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN`
(`mode:subagent`), executing triage `5852239622`. 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 held `rows` / `original`
belong to: one `useState`, `rowsHeldFor`, written wherever the rows are
written — beside a load's commit (`setRowsHeldFor(parentId)` next to
`setRows` / `setOriginal` / `setDirty(false)`), inside the CURRENT
load's `catch` that fails while nothing is held (an updater that keeps a
value already held and otherwise adopts `parentId`, inside
`isCurrent()`), and in `onChange` for an edit made before any load has
settled (the same keep-or-adopt updater, `parentId` narrowed to a string
first). One predicate, `heldForAnotherParent = rowsHeldFor !== undefined
&& rowsHeldFor !== parentId`, is read at three sites: the Save button's
`disabled`, the first statement of `save` after its existing
`dataSource` / `parentId` / `childObject` guard (it returns before
`setSaving`, 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 of `LineItemsField`. `save`'s and `onChange`'s
dependency lists gain what they read (`heldForAnotherParent`,
`parentId`).
- Unchanged: the rows commit's three existing statements (`setRows` /
`setOriginal` / `setDirty(false)`) and the `loading` release
(objectui#10712's PR 2 surface), the `loadSeqRef` / `isCurrent()` error
rule from PR objectui#10738, `save`'s two writes to `error`, every other
dependency list, every hook call, prop, export and registration. The
commit block gained the `setRowsHeldFor(parentId)` line and the `catch`
was 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 real `SchemaRenderer` and
`record:line_items`, every `find` and `batchTransaction` a 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 controls

A throwaway probe (deleted, never committed) through the real
`SchemaRenderer` and `record:line_items`, ids explicit. The column is
`label` (aria-label `Line`), the child object `po_line`, the
relationship field `po`. Readings at base, then at the current head
`854e0922cc` (whose only change since `ef16b93486` is prose: the
readings are the pin's, re-run green there):

| sequence | base `5ad3b88861` | head `854e0922cc` |
|:--|:--|:--|
| `p1` answers `l1`, edited to `p1 line edited`; the host re-renders
with `parentId: 'p2'`; `p2`'s load rejects `p2 load failed`; then Save |
banner `p2 load failed`; drawn lines `['p1 line edited']`, inputs
enabled; **Save enabled**; Save ⇒ `batchTransaction` ops `[{object:
'po_line', action: 'update', id: 'l1', data: {label: 'p1 line edited',
po: 'p2'}}]` (`find` 2 calls, `batchTransaction` 1) | banner `p2 load
failed`; drawn lines `[]`, no grid, the placeholder drawn; **Save
disabled**; no batch (`find` 2, `batchTransaction` 0) |
| **R7** (the round-3 route): mount `p1` with NO adapter (`load` returns
at `!dataSource`, the grid is drawn, `rowsHeldFor` stays undefined);
type `typed under p1`; the host moves to `p2`, still no adapter; the
adapter arrives and `p2`'s load fails; then Save | grid drawn with
`['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 | placeholder drawn, `[]`, **Save disabled**, no batch
|
| lit control: the first row, but `p2`'s load answers `l2` `p2 line` |
drawn `['p2 line']`, no banner, Save disabled (nothing dirty); edit `l2`
+ Save ⇒ `[{object: 'po_line', action: 'update', id: 'l2', data: {label:
'p2 line edited', po: 'p2'}}]` | identical |
| same-parent failed RE-load: `p1`'s edit held, a host re-render with
`limit: 200` re-runs `p1`'s load, which rejects `p1 reload failed` |
banner; drawn `['p1 line edited']`, inputs enabled; Save enabled; Save ⇒
`[{object: 'po_line', action: 'update', id: 'l1', data: {label: 'p1 line
edited', po: 'p1'}}]` | identical (the edits are kept under their own
parent) |
| the DECLINE path: `p1`'s edit held, a re-render with `parentId: 'p2'`
and a refused filter (`{note: {$regex: 'a.c'}}`); the load declines
before any fetch | malformed-filter notice drawn, no grid; **Save
enabled** over the held (off-screen) rows; Save ⇒ `[{object: 'po_line',
action: 'update', id: 'l1', data: {label: 'p1 line edited', po: 'p2'}}]`
(`find` 1 call: the load never ran) | Save disabled; no batch |

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 cleared

The 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:

1. **The decline path.** The DECLINE row above: a swap to `p2` with a
refused filter (and, by the same code path, a lost `dataSource`)
declines the load before its `try`, so a clear in the `catch` leaves
`p1`'s held rows saveable under `p2`. The guard reads the rows' own
parent and closes this path too (pinned; red at base).
2. **One writer of the owner per writer of the rows.** `rowsHeldFor`
moves where `rows` move (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 moves `parentId` and the effect that starts the new
load: `rows` are still `p1`'s, `rowsHeldFor` still says `p1`, Save is
off in that frame too. R7 is the case where this failed at the round-2
head: `onChange` moved the rows without moving `rowsHeldFor` while it
was unset, so a line typed under an adapter-less mount stayed ownerless
until the next failure adopted it. `onChange` now writes the owner too.
3. **The same-parent rule.** A transient failure on a refresh must not
discard the author's unsaved edits (the RE-load row, and the "RE-load of
the SAME parent" pin). Held rows are never cleared, so a swap `p1` →
`p2` (failed) → `p1` (failed) draws `p1`'s held edit again under `p1`
(pinned as a control, "a swap BACK to p1"). A clear on the `p2` failure
would have discarded that edit.
4. **Adoption before a settle.** With nothing held yet, the parent on
screen is adopted the moment the rows first take a value: an edit made
in an unsettled grid (`onChange`, R7's fix), or the empty rows a CURRENT
load's failure leaves on screen (the `catch`, inside `isCurrent()`).
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 for `p1` fails, a line is added, the panel moves to
`p2` whose load fails ⇒ the added line is drawn under `p2` with Save
enabled (the defect, as a `create`). Pinned; red at base. A superseded
failure adopts nothing (the `catch` is inside `isCurrent()`).
5. **What the refused state draws.** After a failed load, the failure's
banner (the adapter's message, as before) and a muted placeholder where
the grid would be; after a refused-filter decline, that filter's own
notice, as before (only Save moves on that route); after a lost-adapter
decline, the placeholder alone. No line, editable or otherwise. A first
load that fails with nothing held is unchanged: banner plus the empty
grid with its entry row (pinned as a control). A load for the current
parent that commits takes the grid back (pinned).

The function-level guard in `save` is the write contract's own check, on
the function every caller reaches, in the same spirit as the
`childObject` guard 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 reaches `save` while the button is off.
See A2 below.

## H3 — every path from the held rows to a write

1. **`save`** → `buildMasterDetailEditBatch(parentObject ?? '',
parentId, parentPatch, [{rows, original, childSchema, …}])` →
`runBatchTransaction`. Guarded at both readers: the button's `disabled`
and the function's first statement. The rollup `sumRows(rows, …)` is
computed inside the same function, behind the same guard.
2. **`onChange`** → `setRows(next)`, `setDirty(true)`, and the owner
adoption. While the grid is drawn, `rowsHeldFor` is 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).
3. **The load's commit** → `setRows` / `setOriginal` / `setDirty(false)`
/ `setRowsHeldFor(parentId)`: the writer that defines whose the rows are
on a successful load.
4. **`LineItemsField` (`GridField` in `@object-ui/fields`)**: `grep -c
useEffect` on `GridField.tsx` at this base = 0; every `emit` /
`onChange` call sits in a user handler (`setCellValue`, `addRow`, the
remove handler, the reorder handler), all routed through the panel's
`onChange` in (2). Nothing changes rows without user input, the reading
PR objectui#10650 made and this PR re-took.
5. **No values bubble up.** The panel takes `schema` only; the
`record:line_items` registration (`LineItemsPanelRenderer` inside
`ElementDataSourceGate`) passes nothing else, and the post-save `load()`
reads no rows. No `forwardRef` / `useImperativeHandle` / key handler;
`save` is referenced once, at `onClick={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 equals
`parentId`, and the first edit sets it to `parentId`.

## Pins and ablations

`LineItemsPanel.heldForAnotherParent-10740.test.tsx`, nine rows. Base
and ablation legs ran on COMMITTED source (`ef16b93486`, panel blob
`5884928c7a`) under `os-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's `scripts/ablation-replace.mjs` in WRAP mode (anchor
declared to hit once, counts and blob hashes read back from disk,
restore spelled `git checkout HEAD --` on the absolute path and proven
by blob == HEAD and an empty `git diff HEAD`). Two whole-file base legs,
each swapped in by hash under a shell `trap` and restored the same way:
the card base blob `0c498ce007` (`held ?? parentId` occurrences 0), and
the round-2 head blob `47696a5819` (occurrences 1 — the fix before R7
was closed). No build is on the resolution path: the pin imports
`./index` relatively and every `@object-ui/*` specifier resolves to
`src` through the root vitest alias, so `ablation-dist-preflight` does
not apply.

| row | base `0c498ce007` | round-2 head `47696a5819` | head
`854e0922cc` | red under |
|:--|:--|:--|:--|:--|
| p1 edited, swap to p2 whose load FAILS: no p1 line drawn, Save off, no
batch | **red** | green | green | A1, A3 |
| the same through a load that DECLINES (refused filter): Save off,
nothing sent | **red** | green | green | A1 |
| a line added under a FIRST load that failed, then a swap to p2 whose
load fails: not drawn under p2, Save off | **red** | green | green | A1,
A3 |
| **R7**: a line typed under an ADAPTER-LESS mount on p1, a swap to p2
with no adapter, then the adapter arrives and p2's load fails: no line
drawn under p2, Save off, no batch | **red** | **red** | green | A1, A3,
A6 |
| control: a swap to p2 whose load SUCCEEDS replaces p1's lines; a save
after editing p2's line carries `l2` under `p2` and nothing of `l1` |
green | green | green | A4 |
| control: a RE-load of the SAME parent that fails keeps the held edit
drawn, editable, Save on; the save carries `l1` under `p1` | green |
green | green | — |
| after a refused state, a load for the CURRENT parent that commits
takes the grid back | **red** | green | green | A3, A4 |
| control: a swap BACK to p1 whose load fails again draws p1's held edit
again | green | green | green | — |
| control: a FIRST load that fails draws the banner and the empty grid,
as before | green | green | green | — |

- **Base leg, card base blob `0c498ce007`**: `Failed Tests 5` (the four
refusal rows plus the recovery row), 4 green. Restore proven (blob ==
HEAD `5884928c7a`, `git diff HEAD` empty).
- **Base leg, round-2 head blob `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.
- **A1**, the button's `|| heldForAnotherParent` removed: 4 failed (the
three original "Save off" rows and R7).
- **A2**, the `save` guard neutralised (`if (false &&
heldForAnotherParent) return;`): **0 failed, 9 passed.** As under H2:
with the button guard in place no UI click reaches `save`, so this hunk
is observable only together with A1. Kept as the write contract's own
guard, not claimed as pinned.
- **A3**, the render branch made unreachable (`false &&
heldForAnotherParent ?`): 4 failed (the two "not drawn" rows, R7 and the
recovery row).
- **A4**, `setRowsHeldFor(parentId)` deleted from the load commit: 2
failed — the "swap to p2 SUCCEEDS" control and the recovery row, both at
`expected [] to deeply equal ['p2 line']`: with the commit no longer
branding the rows, `rowsHeldFor` stays at the value the p1 edit adopted,
so p2's own freshly loaded lines are refused behind the placeholder.
- **A5**, the `catch` failure-adoption deleted: **0 failed, 9 passed.**
Since Round 3, `onChange` also adopts, so every row whose owner the
`catch` used to set now has it set by the typed edit (or is an empty
grid, never saveable). The `catch` adoption is retained as the
documented adopt-on-failure behavior; like A2 it is kept and not
independently pinned. (At the round-2 head, before `onChange` adopted,
A5 reddened the "line added under a FIRST load that failed" row.) Its
one remaining observable effect is the B5 Acceptance note below.
- **A6**, the `onChange` owner adoption deleted (`setRowsHeldFor((held)
=> held ?? owner);`): **1 failed — exactly the R7 row.** This is the
ablation of the round-3 close.
- Each ablation leg printed `ok mutation landed: anchor 1 to 0` with the
blob change, and `ok restored: blob == HEAD (5884928…) and git diff
HEAD is empty`; the tree was clean before and after (`git status
--short` empty).

## Gates at head `854e0922cc`

Locked runs (`os-verify-lock`, `VERDICT command-exit 0` on 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 with
`tsc --noEmit && tsc -p tsconfig.test.json` echoed; `--listFilesOnly`
counts the pin once in the `tsconfig.test.json` program and
`LineItemsPanel.tsx` once in the build program.
- `pnpm --filter @object-ui/plugin-form build`: exit 0, `[unplugin:dts]
Declaration files built`, `dist/index.js` emitted — 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.js`
sha256 `9e08b5589db5…`, `dist/index.d.ts` `08fe75fa41b4…`,
`dist/index.umd.cjs` `19fddff1b34f…`, built at `ef16b93486` and again at
`854e0922cc`.
- the pin: `Tests 9 passed (9)`. The seven existing `LineItemsPanel*`
pins plus `recordFormsErrorClears-10682.test.tsx` at `ef16b93486`: `Test
Files 8 passed (8)`, `Tests 102 passed (102)` (the later commit moves no
code token).
- the whole package, `pnpm exec vitest run --maxWorkers=2
packages/plugin-form/`, at `7352890ae4` (round 1): `Test Files 131
passed (131)`, `Tests 1507 passed | 1 skipped (1508)`. The later commits
touch only `LineItemsPanel.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 at `ef16b93486` (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 at `854e0922cc`: `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-major` and
`check-changeset-fixed` at `ef16b93486`. `check-governed-merges.mjs
--test` over 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 is `eslint.config.js`'s
`files: ['**/*.{ts,tsx}']`; `--print-config` on `LineItemsPanel.tsx`
shows `parserOptions: {}` and no `project` / `projectService`, so no
rule is type-aware and this diff cannot move the verdict of a file it
does not touch. `LineItemsPanel.tsx` carries 18 warnings, the same count
and rule multiset as its base blob (14 `no-explicit-any`, 2
`set-state-in-effect`, 2 `exhaustive-deps`); the pin carries 12
`no-explicit-any`.

**NOT MEASURED locally, declared to CI**: the repo-wide `pnpm lint` and
the rest of the farm.

## Serial

At claim time no open PR touched `LineItemsPanel.tsx` (claim
`5852470959`). Before the round-1 pushes `origin/main` was `01700dcd7d`
(four commits past the base); before the round-4 push it is
`704e05b09d`, nine commits past the base. `git merge-tree --write-tree`
against it exits 0 with a clean tree (`be8cdeefbd`); at the round-5
review `origin/main` is `f308a655b8` (objectstack-ai#10708, eleven commits past the
base), against which the tree is `01802fbba7`, also clean. None of the
eleven touches a file this branch touches (objectstack-ai#10708 touched
`skills/objectui/` only); objectstack-ai#10752 (`704e05b09d`) touched
`packages/plugin-form` elsewhere (six form arms,
`recordInvalidation.ts`, `formArmsInvalidation-10715.test.tsx`, and the
header comment of the sibling pin
`recordFormsErrorClears-10682.test.tsx`), and objectstack-ai#10749 moved
`pnpm-lock.yaml`. objectui#10712's PR 2 is serial behind this PR, as the
claim says.

## H5 — pending changesets

`git grep` over `.changeset/` for `LineItemsPanel`, `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` (the `childObject` declines and
`save`'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:
- a late `p1` answer landing after `p2` committed: base draws `['p1
line']` under `p2`; head draws the placeholder (`rowsHeldFor` moved to
`p1` with 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.
- a save started on `p1` that lands after the swap to `p2`: its captured
`load` issues `find` with `po: 'p1'`; base draws `['p1 line edited']`
under `p2`; 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 `11bf099f11` FAILED 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.

- **Round-3 range `11bf099f11..ef16b93`**, 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: `23f6bbc0` closed R7 in `onChange` and fixed
the wording; `ef16b93486` gave the `onChange` owner updater a type-safe
spelling (see Deviations).
- **The close.** `onChange` now writes the rows' owner beside the rows
when nothing is held yet — the seat's ruling permits the `onChange` form
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
releasing `loading`, objectui#10712's surface), not only the two the
ruling enumerated. `parentId` is narrowed in an `if (parentId)` block
before the updater closes over it.
- **The pin.** The new R7 row is red on the card base blob AND on the
round-2 head blob `47696a5819`, green at head, and reddened by A6 (the
`onChange` owner adoption deleted). Table above.
- **Round-3 wording follow-up, `854e0922cc`** (prose and comment only;
the round-3 contract review `5853196779` passed 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 lines
`21fc281303c02040481bc4b368c13334` before and after). The `onChange`
comment'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 `.tsx` hunk has zero non-comment lines (`git diff
ef16b93 854e092 -- 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 three `dist/` artifacts
hash-equal before and after (Gates above).
- **Gates.** The plugin-form dependency-closure build, `type-check`
(both `tsc` programs) and `build` (the dts plugin) all exit 0 at
`854e0922cc`; the pin (9) is green there; the seven sibling pins plus
`recordFormsErrorClears-10682` (8 files / 102) and the light gates are
green at `ef16b93486`; the changeset gates, `check:control-bytes` and
`check:new-line-citations` are green at `854e0922cc`, listed above.

## Acceptance notes

- **The function-level guard is unpinned.** A2 moves no row, for the
structural reason under H2 and in the pin's header. Kept as the write
contract's own check; dropping it is a one-line change if the seat
prefers pins-only code.
- **The `catch` failure-adoption is unpinned since Round 3.** A5 now
moves no row: the `onChange` adoption 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.
- **B5.** After a FIRST load that fails with nothing typed, a swap to
another parent whose load also fails draws the placeholder, not the
empty grid (base drew the empty editable grid); the catch adoption's one
observable effect now that `onChange` adopts. Unpinned.
- **The refused state's placeholder** is hard-coded English, like this
panel's other placeholders (`Loading…`, `Save the record first to add
line items.`, the `childObject` hint); only the malformed-filter state
goes through `createSafeTranslation`. Observation, not filed.
- **A superseded save's failure** is still written unguarded (PR
objectui#10738's note); not re-measured here.
- **A first load that fails** still draws an empty editable grid under
the banner, as before this card; a line typed there now belongs to that
parent and stays with it (H2 item 4).
- No class (a), (b) or (c) finding beyond the objectui#10712 rows its
release comment already carries.

## Deviations

- **Attribution.** Commit trailers are the dispatch's model-free pair,
and this body ends with the role file's session-URL footer. The harness
attribution reminder asked for other lines.
- **The seat's prescribed close needed a type-safe spelling.** The
ruling's line `setRowsHeldFor((held) => held ?? parentId)` (and
equivalently the `onChange` form) does not type-check: in `onChange`,
`parentId` is `string | null | undefined` (`RecordContextValue.recordId`
is `string | null | undefined`) with no `load` guard above it to narrow
it, and the setter's state is `string | undefined`, so the updater's
`held ?? parentId` widens to include `null` — CI job 108556174339 red at
`src/LineItemsPanel.tsx` `error TS2345` on commit `23f6bbc0`, breaking
the plugin-form build and the six gates behind it. Fixed in `ef16b93486`
by capturing the narrowed value: `if (parentId) { const owner =
parentId; setRowsHeldFor((held) => held ?? owner); }`. Semantics
unchanged (a grid is only offered with a parent bound). The `catch`'s
copy of the line type-checks because `load`'s early `if (!dataSource ||
!parentId) return;` narrows `parentId` to a string before it.
- **The ablation legs run from inside the worktree**, because
`ablation-replace` resolves the repository from its cwd; run through the
lock wrapper's default cwd it exits 2 at its own prerequisite (nothing
written, nothing measured).
- **A removed pin row (Round 1).** The pin's first commit carried a
"defence in depth" row that cleared the Save button's `disabled` on the
DOM node and clicked. React filters a click by the fiber's `disabled`
prop, not the DOM property, so the click never reached `save` and the
row passed vacuously (A2 showed it). It was removed in `11bf099f11`
rather than kept as a false pin; the pin header states what it would
have pinned and why nothing can.
- **Round-4 body corrections (published text only).** The round-3 review
found four false evidence statements in the returned body and two
overstatements; all are replaced here with the record's text: the pin
table's "red under" column (A4 reddens only the p2-SUCCEEDS control and
the recovery row), the A4 bullet's cause, the Serial paragraph (main
re-read before the push, the merge-tree re-run at the head), the round
numbering (`11bf099f11` was round 2; `23f6bbc0` and `ef16b93486` are
round 3), H3's closing and H2 item 2.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(skills): protocol.md attributes the six-key breakpoint vocabulary to BreakpointColumnMapSchema — an owner objectstack#11027 retires

3 participants