Repository navigation
test(spec-parity): member pins for object-form.fields and object-grid exportOptions / bulkActions / bulkActionDefs (objectui#8071 slice 1) - #8737
Conversation
…ject-grid's three action/export keys objectui#8071's first declared slice: convert four of the 62 exempted array/object-armed inputs into named per-block member pins, delete their exemptions and lower the ratchet with them (62 -> 58). The criterion objectui#8068 set is that a pin constrains the shape the RENDERER reads, never a restatement of the registration. Measured per key: - object-form.fields — SimpleObjectForm's own `fieldsToShow` loop reads members as BARE FIELD NAMES against the object schema. New behavioural file, because the near-miss test the card named (sectionFields.spec-parity.test.ts) pins the neighbouring `sections[].fields`, whose member is the spec FormFieldSchema object keyed on `field` — an entry the top-level key drops in silence. - object-grid.bulkActions / .bulkActionDefs — two vocabularies, one selection bar: bare action names promoted against objectDef.actions, versus full BulkActionDefs left as authored. Both cross-substitutions are pinned. - object-grid.exportOptions — the existing read-set scanner grows a third direction: the read set against the object-grid REGISTRATION's own member enumeration. Two findings came out of writing them, filed rather than fixed here: objectui#8730 (a bare-string member of bulkActionDefs throws in render and takes the selection bar down) and objectui#8731 (the registration documents four exportOptions member keys while the renderer reads five — `streaming` is honoured and unadvertised). Refs #8071 (object-form.fields, object-grid.exportOptions, object-grid.bulkActions, object-grid.bulkActionDefs) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
…nused-imports rule refused Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ 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
|
|
Heads-up from the PM seat driving objectui#8730 — one row in this PR will go red, and here is exactly which and why. PR #8741 (objectui#8730) has just been flipped and armed: it makes That is the behaviour your Nothing here is a criticism of that pin — it was right. objectui#8071 recorded it as current shape, not contract, precisely so it would be rewritten rather than trusted, and it is the reason objectui#8730 exists as a card at all. Ordering, so neither of us wastes a queue cycle. Whichever of the two lands second pays for the rewrite; the merge queue's speculative build catches it either way, so
If this PR is closer to landing than that, say so and I will hold #8741 — it is armed, not merged, and disarming costs nothing. What row 5 should become, measured on #8741's branch rather than guessed:
Your row 2 — Two independent readings agreed from opposite directions here, which is worth recording: your seat wrote the pin, and objectui#8730's dev measured its absence from Generated by Claude Code |
Ejected from the merge queue at 03:44:58Z — the cause is row 5's pin doing exactly what it was built to do
The event log — action AND actorThe failure — one test out of 9305Merge-group ⭐ This is the pin SUCCEEDING, not a flake and not a defect in this PR. Row 5 deliberately pinned objectui#8730's crash as current behaviour, and this PR's own body states the contract it was written under:
The fix landed. ⇒ The PR's own CI was green because it ran against a base that predates the fix; the queue tests the merged-with-main candidate, which is where the two met. ⛔ Nothing here argues for a re-run. What the repair doesRow 5 moves onto the new contract — skipped, diagnosed, bar survives — rather than restoring the old assertion. ⛔ Not weakened to "does not throw", which would pass against a bar that renders nothing. The stale
⛔ The dev does not re-enqueue. This seat enqueues after verifying the fix on the pushed head. Generated by Claude Code |
…ber-pins-near-miss-four
…ose contract that landed Row 5 pinned objectui#8730's crash as the CURRENT shape, with the issue filed from it, so that landing the fix would RED the row rather than leave it describing nothing. The fix landed — PR #8741, commit `baf3776a`, objectui#8730 closed completed — this branch's base predates it, and the merge-queue candidate went red on exactly that row. This is the rewrite the row was built to force. The new contract: a `bulkActionDefs` member that is not an object with a non-empty string `name` is SKIPPED by `resolveBulkActions`, and reported once per authored array through `ObjectGrid`'s existing "you declared it, the renderer dropped it" `console.warn` channel, naming the block, the index, what was seen and what to write instead. The row keeps its discriminating job — a bare name is not this key's vocabulary — and asserts three things a bare "does not throw" would not: - SKIPPED, not coerced. `'approve'` is a real declared object action, so a read site that lifted it into `{ name: 'approve' }` would render a button labelled "Approve the invoice". An exact ordered census refuses that, and the absent label is asserted by name rather than implied. - EXACTLY ONE MEMBER WIDE. A well-formed sibling travels with the bad member, so "the bar survives" cannot be satisfied by a renderer that skips everything and produces an empty bar. - DIAGNOSED. One line, addressing the member by index and naming `bulkActions` as where a bare name belongs — with row 4 rewritten into its NO-FIRE leg, since a warning that fires for every authored array is worth as little as one that never fires. Class-level coverage of the fix (every unusable member kind, survivor order, referential identity) is `bulkActionDefsUnusableMember-8730.test.tsx`; this file keeps the member-VOCABULARY half it was written for. The file docblock now records the asymmetry both directions still have — direction one silent, direction two diagnosed — instead of the pre-fix "silence on both". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
… absent-label assertion
`expect(queryByText('Approve the invoice')).not.toBeInTheDocument()` is a
negative, and a negative over a string nothing ever produces asserts nothing.
Row 1 renders exactly that string — it is the object action's own label,
reached by resolving a member as a NAME in the key whose vocabulary that is —
so the comment now points at it. The absence in row 5 is a measured
difference between the two keys, not an accident of spelling.
Comment only; no assertion changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ 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
|
…nitialValues`, `initialData` and `submitBehavior` (objectstack-ai#9759) Refs objectstack-ai#8071 Clause-②: no objectui#8071 slice 10. Three `object-form` keys leave `MEMBER_PIN_EXEMPTIONS` and gain real per-block member pins. `MEMBER_PIN_EXEMPTION_CEILING` follows **31 -> 28** in the same commit, as every slice of that card owes. ##⚠️ First: the dispatch's own premise was FALSIFIED, and the card body is the reason This batch was dispatched to convert the three "near-miss" keys the card body flags — `object-form.fields`, `object-grid.exportOptions`, `object-grid.bulkActions` / `bulkActionDefs` — plus `page:accordion.items`, `page:tabs.items`, `record:path.stages`, `record:alert.action`, `element:button.action` as follow-on candidates. **All nine have been pinned since slices 1-7.** Measured on `origin/main` `f73521f93` by TypeScript AST walk of the constant, not by reading the card: ``` MEMBER_PIN_EXEMPTIONS on origin/main = 31, in five blocks object-grid. 15 object-form. 7 object-master-detail-form. 6 object-kanban. 2 (held: `dataSource` is not a block member at all) record:related_list.actions 1 (NO_READ_SITE_TO_PIN — a different debt) ``` The card body's enumerated 58-key list has been stale since PR objectstack-ai#8737 merged on 2026-09-09, and issuecomment-5599611864 already recorded that the authoritative list is the constant, never the card's prose. It has now mis-aimed a dispatch a second time. That note is repeated inside the constant's own docblock in this PR so the next dispatcher reads it where the numbers live. ⇒ the batch was re-selected from the live table. `object-kanban` (held on a stated question) and `record:related_list.actions` (a different debt) were left alone; `object-form` was taken because it is the smallest of the three open multi-key blocks. ## What was converted, and the renderer read pinned for each ### `object-form.initialValues` and `object-form.initialData` — NEW file `packages/plugin-form/src/__tests__/objectFormInitialMembers-8071.test.tsx` Read site: `ObjectForm.tsx`, the two sites that seed `initialData` state (the inline-fields effect and the create branch of `fetchInitialData`), both spelled `setInitialData(schema.initialData || schema.initialValues || {})`. 1. a MEMBER is a FIELD NAME and its value is that control's opening value; 2. ⭐ the two keys are chosen between **as whole objects** — `||`, never a merge. With both authored, **every** `initialValues` member is dropped, including the ones `initialData` says nothing about. Nothing declared distinguishes that from `{ ...initialValues, ...initialData }`, which is what "alternate spelling … read FIRST" reads like and what a per-member precedence would mean; 3. the sharp edge: `||` tests the OBJECT's truthiness, so an **empty** `initialData` shadows a populated `initialValues` completely; 4. non-vacuity control: with neither key authored the same controls render empty. Both keys are registered `type: 'object'` and typed `Record<string, any>`, so every object parses on both declared sides — the read site is the whole member contract. ### `object-form.submitBehavior` — EXISTING file, GROWN before it was registered `packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx` This is a genuine near miss, unlike two of the three the card named at slice 1: its whole subject is the key and it drives the real renderer. But it left two members of the read unasserted, so it was strengthened rather than credited as found. Read site: `ObjectForm.tsx`'s `switch (behavior.kind)`. Every member is touched once there — `url` and `delayMs ?? 0` on `redirect`, `title` and `message` on `thank-you` (both guarded on the discriminant, so no other arm reaches them), and nothing at all on `continue` or `next-record`. - **`delayMs` is a MEMBER, not a constant.** Collapsing `behavior.delayMs ?? 0` to a bare `0` removes the readable pause without changing any destination, so every other row in this file and in `ObjectForm.submitRedirect.test.tsx` stays green. Pinned on the number the wait is armed with, with the **same declaration minus the member as the lit control** — without it, an empty `armed` would be indistinguishable from a spy that never sees this renderer's timers. - **`next-record` is a declared arm that reads NO members.** It shares the confirmation-panel body with `thank-you` by falling through, so an edit giving it its own arm or dropping it into `continue`'s no-op would leave a spec-legal authored value doing something else with nothing red. ## Ablations — every pin shown FAILING, restore proven by blob Each from the committed state, `trap … EXIT INT TERM`, restore by `git checkout HEAD -- path` (never a bare checkout), on-disk proof read **before** any result. | leg | mutation | anchor before/after | result | |---|---|---|---| | 1 | `schema.initialData \|\| schema.initialValues \|\| {}` → `{ ...(schema.initialValues ?? {}), ...(schema.initialData ?? {}) }` (the plausible "improvement") | 2 → 0, injected 2 | **exit 1** — rows **3 and 4** red, rows 1/2/5 green **by construction** | | 2a | `delayMs: behavior.delayMs ?? 0` → `delayMs: 0` | 1 → 0, injected 1 | **exit 1** — the `delayMs` row red, 6 passed | | 2b | `case 'next-record'` moved into `continue`'s no-op arm | verified at `:1105-1106` | **exit 1** — the `next-record` row red, 6 passed | Blobs: HEAD `42b7a9b0855c5a53276af4f1d9b3842ad6a0899d`; mutated `30e2c464…` / `e50a99be…` / `ce8521db…`; restored `42b7a9b0…` = the HEAD blob in all three legs, `git diff HEAD` empty each time. Restored runs: pin A **5 passed**, pin B **7 passed**.⚠️ Rows 1, 2 and 5 of pin A survive leg 1 **by construction** and the file says so — a merge and a `||` agree when only one key is authored. Not every row is presented as if it reddened. ## Exemption ledger — measured by AST, not by arithmetic TypeScript AST walk (`ts.createSourceFile` → `VariableDeclaration` → initializer unwrapped through `as` / `satisfies` / parens → **direct** `PropertyAssignment` children): ``` origin/main (f73521f) this branch MEMBER_PIN_EXEMPTIONS 31 28 MEMBER_PIN_EXEMPTION_CEILING 31 28 MEMBER_PINS 60 63 NEWLY_JUDGED_UNPINNED_MEMBERS 2 2 ``` Exemptions **−3**, pins **+3** — they move together, so the three keys were **converted**, not dropped. Instrument control: `nonPropertyAssignmentMembers` is `[]` on both trees, so no spread, getter or shorthand hides inside a span the counter treats as flat. `object-form` is **not** closed: `customFields`, `dataSource`, `mobile` and `sections` remain — the two-bite shape slice 9 said every remaining block now has. `NEWLY_JUDGED_UNPINNED_MEMBERS` and `record:related_list.actions` are untouched by this slice. ## Gates — exit codes captured to disk before any pipe | gate | exit | |---|---| | `apps/console` `registry-inputs-spec-parity.test.ts` (the gate this card serves) | **0** — 199 passed | | `pnpm --filter @object-ui/console test` | **0** — 101 files, 1158 tests | | `pnpm --filter @object-ui/plugin-form test` | **0** — 93 files, 902 passed / 1 skipped | | `pnpm --filter @object-ui/plugin-form type-check` | **0** | | `pnpm --filter @object-ui/console type-check` | **0** | | `pnpm --filter @object-ui/plugin-form lint` · `@object-ui/console lint` | **0** · **0** (0 errors) | | the repo's 61 `check:*` scripts | **60 × 0**, one caveat below | | `check-governed-queue-guard.mjs --test` over the four changed paths | **0** — NOT GOVERNED, so no maintainer-summary section is owed |⚠️ **`check:node-esm-load` exited 1 on the first run, and it is not this diff.** Its own provenance leg refused 2 of 37 entries because turbo's shared cache replayed artifacts built by a *different worktree* (`/home/user/objectui-issue-9659` — `@object-ui/auth`, `@object-ui/react-runtime`), neither of which this diff touches. The script names that condition and prescribes the remedy itself; re-run with its own `--force-build`: **exit 0, 37 of 37 gradable entries built by this tree.** **Typecheck coverage proven, not assumed:** `tsc -p tsconfig.test.json --listFiles` lists both `ObjectForm.submitBehavior.test.tsx` and `objectFormInitialMembers-8071.test.tsx`, so the greens are not the excluded-tests trap. **Control bytes:** `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` over all four changed files — no match (and `check:control-bytes` exit 0). ## Changeset grade — MEASURED from a build, with both controls Empty-frontmatter changeset. Both packages were built and their published `files[]` output grepped: | marker | corpus | hits | |---|---|---| | `objectFormInitialMembers` | `packages/plugin-form/dist` | **0** | | `Saved — next one`, `from initialValues` | `packages/plugin-form/dist` | **0** | | `MEMBER_PIN_EXEMPTION_CEILING`, `AWAITING_A_PIN` | `apps/console/dist` | **0** | | `submitBehavior` (positive control) | `packages/plugin-form/dist` | **7 files** | | `object-form` (positive control) | `apps/console/dist` | **12 files** | Two zeros with a lit control on the same instrument and corpus. One near-miss chased down: `registry-inputs-spec-parity` matches **1** file in `apps/console/dist`, and it is `vendor-objectstack-*.js` — `@objectstack/spec`'s own prose naming this gate file, not a byte of this diff. ## Serial constraint — re-taken immediately before the first write Enumerated over the **11** open PRs (the dispatch's table listed 3; this lane's tables have been short before). Intersection at FILE level: **empty**. Nothing open touches `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` or any `packages/plugin-form/src/**`. PR objectstack-ai#8941 touches `packages/plugin-form/package.json` only. Root `package.json` is held by PR objectstack-ai#9752 in the queue — this batch does not need it. ## Out of scope — handed back, not filed and not fixed - The **233** array/object inputs on blocks with no spec contract entry at all are explicitly not this card. Nothing here widens into them. -⚠️ An **empty `initialData` silently discarding a populated `initialValues`** is pinned as the renderer's behaviour and deliberately not endorsed. Changing it is a renderer change, and Clause-② for this batch is `no` — so it is reported to the dispatching seat rather than filed or repaired here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- _Generated by [Claude Code](https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L)_ Co-authored-by: Claude <noreply@anthropic.com>
…he pending 8171 body (objectstack-ai#9823) Fixes objectstack-ai#9781 Clause-②: no Scope: one sentence of prose in the pending `.changeset/8171-calendar-sort-input.md`. Frontmatter untouched, and the rest of that paragraph (the `object-calendar.sort` exemption, the objectui#8176 backlog ceiling) is not assessed here. ## The defect, and why the reading is DATED rather than corrected The paragraph ended with: `MEMBER_PIN_EXEMPTION_CEILING` is untouched at 62. That is two claims with different lifetimes: - **"is untouched"** — a bounded statement about what objectui#8171 itself did. True when written, and nothing anyone does later can falsify it. ⭐ KEPT, word for word. - **"at 62"** — an absolute reading of a SHARED constant. ROTTED: somebody else's change falsifies it, and one did. ⇒ The repair DATES the reading at its write-time commit and names the falsifier. ⛔ It does not restate the constant at today's value — that number is true today and false again at the next slice, i.e. this same defect authored a second time. The rule the repaired prose now carries: **state your OWN change's bounded transition ("lowers 62 to 58", "follows 31 to 28 in the same change") or state that you did not touch the constant, and never state what its value currently is.** ## Every number, with the instrument that produced it and the ref it was taken at Established in this worktree, ⛔ not inherited from the card. The repository was NOT shallow when measured (`git rev-parse --is-shallow-repository` prints `false`). 1. **Today's reading — 24, at `42234b1a2cd7540a5d53907a95438a888a6b5021`** (this branch's base, `origin/main` fetched 2026-09-18T06:56Z): `git grep -n -E 'const MEMBER_PIN_EXEMPTION_CEILING = [0-9]+' 42234b1 -- apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` prints `...:3144:const MEMBER_PIN_EXEMPTION_CEILING = 24;`.⚠️ That value moves often; it appears here only as the reason the old sentence is false, and ⛔ it is deliberately not written into the changeset. 2. **The write-time commit — `35e63669d6`, 2026-09-07 (pull request objectstack-ai#8223)**: `git log --oneline -S'is untouched at 62' -- .changeset/8171-calendar-sort-input.md` returns exactly that one commit, and `git log --oneline --reverse -- .changeset/8171-calendar-sort-input.md` shows it is the only commit that has ever touched the file. 3. **62 WAS the value there, so the sentence was TRUE when written**: `git show 35e6366:apps/console/src/__tests__/registry-inputs-spec-parity.test.ts | grep -nE 'const MEMBER_PIN_EXEMPTION_CEILING = [0-9]+;'` prints `2267:const MEMBER_PIN_EXEMPTION_CEILING = 62;`. ⇒ ROTTED, ⛔ not BORN FALSE. 4. **The FIRST falsifier — `240c605939`, 2026-09-09 (pull request objectstack-ai#8737, objectui#8071 slice 1)**: `git log --oneline --reverse -G'^const MEMBER_PIN_EXEMPTION_CEILING = ' 35e6366..42234b1 -- apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` enumerates, in order, the 11 commits that move that line between write time and this base; `240c605939` is the first of them. At it the constant reads 58 (`git show 240c605:apps/console/src/__tests__/registry-inputs-spec-parity.test.ts | grep -nE ...` prints `2437:const MEMBER_PIN_EXEMPTION_CEILING = 58;`) ⇒ the transition that falsified the reading is 62 to 58. Why that one and not a later one: the enumeration is ordered and complete for that line over that range, so no earlier commit in the range touched it. 5. **Ancestry legs**, so that range really is the path from write time to this base: `git merge-base --is-ancestor 35e6366 42234b1` exits **0**; `git merge-base --is-ancestor 240c605 42234b1` exits **0**; the control leg in the opposite direction, `git merge-base --is-ancestor 42234b1 35e6366`, exits **1**, as it must. ## Before/after probe of the changed prose — whitespace-tolerant, both controls, one corpus⚠️ Changeset prose wraps near eighty columns, so a line-anchored `grep -F` reads 0 on text that is plainly there. The instrument normalizes whitespace first (`tr -s '[:space:]' ' '`) and then counts fixed-string occurrences (`grep -o -F | wc -l`). One corpus: the changeset file, base copy vs working copy. | probe | before | after | |---|---|---| | LIT control, spans a wrap, untouched by this change: `the shrink-only objectui#8176 backlog ceiling drops 16 to 15` | 1 | 1 | | ABSENT control: `MEMBER_PIN_EXEMPTION_CEILING is untouched at 24` | 0 | 0 | | REMOVED, spans a wrap: `rather than a member-pin exemption. MEMBER_PIN_EXEMPTION_CEILING is untouched at 62.` | 1 | 0 | | ADDED, spans a wrap, the dated reading: `MEMBER_PIN_EXEMPTION_CEILING is untouched by this change; it read 62 at the commit that wrote this paragraph (35e6366, 2026-09-07)` | 0 | 1 | | ADDED, spans a wrap, the falsifier: `objectui#8071 slice 1 (240c605, 2026-09-09) lowered it 62 -> 58` | 0 | 1 | | KEPT, the original sentence quoted verbatim inside the closing note: `MEMBER_PIN_EXEMPTION_CEILING is untouched at 62` | 1 | 1 | ⭐ The lit control itself spans a wrap, so it proves the instrument rather than merely accompanying it: the same needle under a line-anchored `grep -c -F` reads **before=0, after=0** — the false negative this table exists to avoid.⚠️ Read the paragraph, not the line: the true half stays as the live sentence, the rotted half is preserved verbatim as a dated quotation, and the still-true claim is never deleted along with the false reading. ## Changeset grade for this change — MEASURED, with both controls `node scripts/check-changeset-presence.mjs`, in a worktree with `pnpm install` completed. - **ABSENT control, this change as committed** — exit **0**: "1 file(s) changed, 0 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 0 changeset(s) added", then "No source or published contract of a released package changed in this range, so no changeset is owed." - **LIT control** — one comment line appended to `packages/plugin-calendar/src/ObjectCalendar.tsx`, proven to reach disk (marker occurrences 0 then 1): exit **1**, "2 file(s) changed, 1 of them published source of a package the release covers", "1 source file(s) of 1 released package(s) changed, and this change adds no changeset". ⇒ the gate can fail on this corpus, so the green above is a measurement rather than a silence. - **Restore leg** — `git checkout HEAD -- packages/plugin-calendar/src/ObjectCalendar.tsx`; `git hash-object` on the path equals the HEAD blob `daa84297c2ac6c4a61059a05cc86974750c7295e`, `git diff HEAD` for it prints nothing, and the gate re-reads exit **0**. ⛔ No changeset is added for this change, and ⛔ the `skip-changeset` label is not applied — in this repository nothing reads it; the gate's own verdict line above is the authority. ## Gate exit codes — each captured to disk BEFORE any pipe Written with the redirect first and the status read from the file afterwards, never through a pipe. | gate | exit | |---|---| | `node scripts/check-changeset-overwrite.mjs` (case 2: a pre-existing changeset modified on purpose; report-only) | 0 | | `node scripts/check-changeset-presence.mjs` | 0 | | `node scripts/check-changeset-claims.mjs` | 0 | | `node scripts/check-changeset-fixed.mjs` | 0 | | `node scripts/check-changeset-no-major.mjs` | 0 | | `node scripts/check-pending-changeset-literals.mjs` | 0 | | `node scripts/check-control-bytes.mjs` | 0 | | `node scripts/check-new-cross-file-line-citations.mjs` | 0 (0 new citations) | | `pnpm exec vitest run` over the four `scripts/__tests__/` changeset suites, under the shared verify lock, slot `issue-9781` | 0 — 4 files, 126 tests passed | The overwrite gate classifies this change as its **case 2** and prints both sides of the declaration: "declared at base: @object-ui/plugin-calendar: minor" and "declares now: @object-ui/plugin-calendar: minor" — frontmatter untouched, which is exactly what case 2 asks of a deliberate prose correction. Gate family: the workflows whose path filters include `.changeset/**` are `changeset-guard.yml` (no-major plus overwrite), `ci.yml`, `lint.yml` and `live-e2e.yml`. The repository-wide farm belongs to CI; the targeted set above is what this diff owes locally. ## The window Release pull request objectstack-ai#5400 was open at claim time, re-read **open, not merged** immediately before the first write, and again immediately before the push (2026-09-18T07:03Z). ⛔ Nothing in this branch touches it or any file it owns. If it merges before this lands, the card is re-triaged: the remedy channel becomes a docs-only pull request on a release-owned tree, and the published CHANGELOG copy is already out of reach. Serial constraint, re-taken over the open-pull-request set immediately before the first write: 19 open pull requests enumerated with their full changed-file lists (objectstack-ai#5400 paginated, 1732 files). ⛔ Only objectstack-ai#5400 touches `.changeset/8171-calendar-sort-input.md`, and that is the WINDOW, not a conflict for this lane to settle. The sibling card objectui#9772 works a different pending file; no file is shared, and no verdict here was established by analogy to it. ## Acceptance notes - Placement: the in-paragraph sentence stays short and the provenance goes into a closing note at the end of the body, so the paragraph that follows ("That member pin is deliberately NOT ...") keeps the antecedent sitting directly above it. Two inserted paragraphs would have separated them. - Observed, not filed: `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` carries docblock comments that state this same ceiling in the present tense — "and `MEMBER_PIN_EXEMPTION_CEILING` stays 62", "the reason `MEMBER_PIN_EXEMPTION_CEILING` reads 62" — each attributed to a named past card, so a careful reader can date them from context, but the tense reads live while the constant reads otherwise today. In-tree comments, not pending changeset prose, and outside this card's surface. Carrier: whoever moves that ceiling next. Session: `https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L` --- _Generated by [Claude Code](https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L)_ Co-authored-by: Claude <noreply@anthropic.com>
Refs #8071 (object-form.fields, object-grid.exportOptions, object-grid.bulkActions, object-grid.bulkActionDefs)
⛔
Refs, notFixes. This is the first declared slice of a 58-key card; #8071 must survive the merge.The single progress reading — MEASURED, not predicted
MEMBER_PIN_EXEMPTIONSentriesMEMBER_PINSentriesMEMBER_PIN_EXEMPTION_CEILING♻️ All four RE-MEASURED on the merged head, not inherited from this table.
mainmoved many times since base483b794a, so every number was counted again off disk. All four are unchanged, and the counter was proved to be measuring rather than guessing: the same count run against the merge-base2f881a90and againstorigin/mainanswers28 / 62 / 62on both, so the merge introduced no ledger drift at all. Population is not summed by hand — the gate's own totality and disjointness assertions are green with 32 pinned + 58 exempt, which is what makes 90 a reading.58was objectui#8068's census, and objectui#8176 later corrected the population (fourobject-calendar/object-kanbankeys thatregisterLazyhad hidden fromgetConfig), raising the list and the ceiling to 62. That correction is onorigin/mainand is asserted by name in the gate. The card's own arithmetic — "four keys leave the list" — is unchanged; only the base was stale.⭐ The ceiling moves WITH the list. Leaving it at 62 while the list fell to 58 would have banked four unused slots, and an unused slot is exactly what the ratchet exists to deny: the next genuinely new array-typed key could then be greened with an exemption entry instead of a pin. The constant's docblock now carries that reasoning and the rule for every later slice.
Per key: where the renderer was read, the shape found, the assertion added
1.
object-form.fields— new filepackages/plugin-form/src/__tests__/objectFormFieldsMembers-8071.test.tsxpackages/plugin-form/src/ObjectForm.tsx—SimpleObjectForm's own loop,const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {})and theconst name = typeof fieldName === 'string' ? fieldName : (fieldName as any).nameresolution below it; and atpackages/plugin-form/src/flatFields.ts'sbuildFlatFields, which the drawer and modal presentations share.{ name }spelling.ObjectForm. The sharp one is row 4/5: the specFormFieldSchemaobject — identity keyfield, the canonical member ofsections[].fields— resolves to no name here and is dropped in silence (no throw, no warning), while the very same entry insidesections[].fieldsrenders, as the live control. Two surfaces spelledfieldson one block, two member vocabularies.packages/plugin-form/src/sectionFields.spec-parity.test.tspinsnormalizeSectionField, i.e.sections[].fields— a neighbouring key. It already contains both the stringobject-formand the wordfields, so per-block spec-parity pin 是自愿的:一个 array/object 类 key 可以完全没有 pin 而闸门照绿——page:header.actions就是那个没被钉住的第四个 #8068's locator would have accepted it for this key on its strings alone. Registering it would have been a fake pin that removed the exemption and left the risk. This is the concrete case for whyMEMBER_PINSis reviewed rather than computed.2.
object-grid.exportOptions— extendedpackages/plugin-grid/src/__tests__/ObjectGrid.exportOptionsKeys.test.tspackages/plugin-grid/src/ObjectGrid.tsxlines 2964, 2968, 2973, 2989, 2990, 2996, 3011 — throughschema.exportOptionsand theexportConfigalias bound to it.formats,streaming,maxRecords,includeHeaders,fileNamePrefix.object-gridregistration's own member enumeration (the block's only statement of member shape: the input istype: 'object'with noof, and the spec row isz.unknown()). Two claims — the registration may advertise no member key the renderer ignores, and the reverse gap is asserted as an exact named set, so a second undocumented key cannot join it quietly.3 + 4.
object-grid.bulkActions/object-grid.bulkActionDefs— new filepackages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsxpackages/plugin-grid/src/ObjectGrid.tsx:3542-3555(schema.batchActions ?? schema.bulkActions, thenresolveBulkActions) andpackages/plugin-grid/src/resolveBulkActions.ts—isUsableBulkActionDef(:180),describeUnusableBulkActionDefs(:258),resolveBulkActions(:283), plus the diagnostic effect atObjectGrid.tsx:2243-2251. (Line numbers re-read on the merged head;baf3776amoved them.)bulkActionsmembers are bare action names, resolved againstobjectDef.actionsand promoted to a full def carrying the action's label, icon, params, confirm text,visibleandrequiredPermissions.bulkActionDefsmembers are fullBulkActionDefobjects, left as authored and never resolved against the object.ObjectGridwithselectiondeclared explicitly on every row (the grid derives multi-select from these very keys, so a negative row would otherwise lose its selection UI for the reason under test). Promotion is read off a button carrying a label that is deliberately not the humanized name. Both cross-substitutions are pinned, and they still fail DIFFERENTLY: an object member ofbulkActionsis skipped in silence; a bare-name member ofbulkActionDefsis skipped and diagnosed — oneconsole.warnper authored array naming the block, the index, what was seen and what to write instead.packages/types/src/__tests__/bulk-action-spec-parity.test.tspins theBulkActionDeftype against the spec. It names neitherobject-gridnorbulkActionDefs, and it never touches either key's member read.object-grid.exportOptions: the registration declaresand the renderer reads five keys.⚠️ It is the documentation form of the trigger, not the type-arm form:
streamingis honoured (exportConfig?.streaming !== falsechooses server-streamed versus browser-assembled export) and appears nowhere on the authoring surface. That is objectstack#8010's exact shape one layer out — the defect this key's own test file was written for, reproduced on the registration side.type: 'object'is not contradicted, the enumerated member key set is one key short. ⛔ Not fixed here — the fix lands in published source and the dispatch fenced published surfaces. Grading is the PM's.Second finding, filed as #8730 — now fixed and closed.
bulkActionDefs: ['approve']— a string member, refused by nothing on either declared side — reachedBulkActionBarwith noname, andformatActionLabel(undefined)threw during render, taking the whole selection bar down on the author's first multi-row selection. It was pinned here as CURRENT behaviour with the issue named, so that landing the fix would red the row instead of leaving it describing nothing.⭐ That is exactly what happened, and it is the reason this PR was ejected from the merge queue. The fix landed as PR #8741 (commit
baf3776a, 2026-09-09T02:58:51Z) — not in this branch's base — the queue's merged-with-main candidate ran row 5 against it, and the row went red. objectui#8730 is closed as completed. The row is rewritten below onto the contract that landed; the finding needed no further action from this PR.Row 5, rewritten — the new behaviour, measured off disk
Re-derived on the merged head from
resolveBulkActions.tsandcomponents/BulkActionBar.tsx, not from the fix's commit message:isUsableBulkActionDef(member)admits only an object carrying a non-empty stringname.resolveBulkActionsfilters the authored array through it —everybeforefilter, so a clean array still comes back BY REFERENCE. "Usable" is defined by what the renderer reads:BulkActionBarusesdef.nameas the ReactkeyAND asformatActionLabel's argument.'approve'is NOT lifted into{ name: 'approve' }and resolved the waybulkActionsis. The docblock says so in as many words; making the two vocabularies interchangeable would be a product change to what abulkActionDefsmember MEANS (objectui#3002 / objectui#3139), not a crash fix.console.warn— notconsole.error— emitted fromObjectGrid.tsx'suseEffectkeyed on thebulkActionDefsschema slice, so once per authored array, not once per member and not once per render. The line is prefixed[ObjectUI] ObjectGrid bulkActionDefs:and names the block (objectName,label), the offending index, what was seen, and that a bare name belongs inbulkActions. It is the channelObjectGridalready owned for "you declared it, the renderer dropped it" (the columns diagnostic, the export-format warning).The row now asserts the skip AND the diagnostic AND the survivors. ⛔ Deliberately not "it does not throw" — that is a tautology a renderer producing an empty bar would also satisfy. So a well-formed sibling travels with the bad member and the census is exact and ordered:
renderedBulkActionIds()must equal exactly['bulk-action-archive']— not[](skip everything), not two entries (skip nothing), and neverbulk-action-approve(coercion).queryByText('Approve the invoice')must be absent. Row 1 is its reachability control: that row proves this exact string DOES render when a member is read as a name in the key whose vocabulary that is, so its absence here is a measured difference rather than a string nothing ever produces.keycomplaint — the second symptom the undefinednameproduced.bulkActionDefs[0],'approve',bulkActionsandobjectName: 'os_invoice'.Row 4 was rewritten into row 5's NO-FIRE LEG. A warning that fires for every authored array is worth as little as one that never fires, so row 5's
toHaveLength(1)only means something beside a well-formed list that produces none. Both legs live in this file so the pair cannot drift apart.The file docblock now records the surviving asymmetry — direction one silent, direction two diagnosed — instead of the pre-fix "silence on both", and states plainly that the row was rewritten and why. Class-level coverage of the fix (every unusable member kind, survivor order, referential identity) belongs to
bulkActionDefsUnusableMember-8730.test.tsxand is not duplicated here; what stays is the member-VOCABULARY fact this file was written for.objectFormFieldsMembers-8071.test.tsx,ObjectGrid.exportOptionsKeys.test.tsandregistry-inputs-spec-parity.test.tspass unchanged — 228 tests across the four touched suites plus #8730's own.Ablation — the REWRITTEN row 5, proven to red four ways
Mutations in
packages/plugin-grid/src/resolveBulkActions.ts(the renderer, never the test), each under anEXIT INT TERMtrap whose restore isgit checkout HEAD --on an absolute path. HEAD blob for every leg:c094482097e68cd495ccae440c6a8de088c9d110.isUsableBulkActionDefaccepts a bare non-empty stringc97bad83…TypeError: Cannot read properties of undefined (reading 'replace'), i.e. the original #8730 crash returnsdescribeUnusableBulkActionDefsalways returnsnullf831bc14…expected [] to have a length of 1skipped.length === 0early return never taken251ad3dd…'approve' → { name: 'approve' }41ee43df…expected ['bulk-action-approve', 'bulk-action-archive'] to deeply equal ['bulk-action-archive']Every leg: on-disk hash pre-mutation equal to the HEAD blob; anchor asserted unique (
1) before the write and0after, with the marker at1and a non-zero byte delta, so no leg was a silent no-op; restored viagit checkout HEAD -- ABSOLUTE_PATH; restoration proved by blob-hash equality to the HEAD blob and an emptygit diff HEAD, with an empty hash treated as FAILURE rather than a pass. No build/distpreflight is owed: the suite imports../ObjectGridand../resolveBulkActionsrelatively, so the mutation reaches the run through source.bulkActionDefs === undefinedpath and reddened rows 1, 2 and 3 for a reason unrelated to the diagnostic. It was replaced by the surgical M2b above. Recorded because a 4-red reading that looks stronger is the exact shape a reader should not accept.Ablation — the object-form pin, proven to red
Mutated the renderer's read site (not the test):
ObjectForm.tsx'stypeof fieldName === 'string' ? fieldName : (fieldName as any).namereplaced with an object-only read, under anEXIT INT TERMtrap whose restore isgit checkout HEAD --on an absolute path.be961dc04ac9987ec556d0c5e784859b945a7701be961dc04ac9987ec556d0c5e784859b945a7701b66b8e9adfd5e4b231f35776b9dabcd4b28cafebbe961dc04ac9987ec556d0c5e784859b945a7701— equal to HEADgit diff HEAD --name-onlyRows 4 and 6 survive by construction and the file says so: row 4 asserts an empty render, row 6 uses the
{ name }spelling the mutation kept.Gates, with exit codes
All re-run on the MERGED head, at final commit
cd2afe91. Exit codes captured before any pipe (cmd > log 2>&1; EXIT=$?), never read through| tailor| head.pnpm exec vitest run— the 4 touched suites +bulkActionDefsUnusableMember-8730(repo root)pnpm exec vitest run packages/plugin-grid/pnpm exec vitest run packages/plugin-form/pnpm exec vitest run apps/console/pnpm --filter @object-ui/plugin-grid type-checkpnpm --filter @object-ui/plugin-form type-checkpnpm --filter @object-ui/console type-checkpnpm --filter @object-ui/plugin-grid lintpnpm --filter @object-ui/plugin-form lintpnpm --filter @object-ui/console lintnode scripts/check-changeset-presence.mjspnpm check:control-bytespnpm checkMODULE_NOT_FOUNDon an unbuiltpackages/cli/dist/cli.js); built the cli, re-ranpnpm check:node-esm-load --force-buildpnpm check:published-distpnpm turbo run build --filter='@object-ui/site'(CI'sBuild Docs)pnpm check:sdui-registration-pinsBuild Docs,check:node-esm-load,check:published-dist, all above.check:node-esm-loadneeded--force-build, and the first reading was NOT a red. Plainpnpm check:node-esm-loadexits 1 with✗ 12 of 37 entries REFUSED — foreign, naming/home/user/objectui-review-8723/and/home/user/objectui-issue-8221-cmp/as the trees those artifacts came from: turbo shares one cache across every worktree of a checkout, and sibling agents' builds were replayed into this one. That is objectui#7276's exact shape — PRECONDITION NOT MET, and the gate names its own remedy in its output. Re-run with--force-buildit reportsProvenance leg: 37 of 37 gradable entries were built by this treeandLoad leg: 34 of 39 imported and evaluated, exit 0. The remaining 5 are the by-design exclusions the gate ledgers itself (2 with no importable entry, 3 unbundled-Node-unsupported by ruling).tsc -p tsconfig.test.json --listFilesshows 1 hit forbulkActionMembers-8071.test.tsx, against a 0-hit control for a path that does not exist. plugin-grid'stype-checkistsc --noEmit && tsc -p tsconfig.test.json, so the test tree is in scope. Not a NOT MEASURED green.TS2307: Cannot find module '@object-ui/components'— unbuilt workspace.d.ts, i.e. PRECONDITION NOT MET, not a red. Built the dependency closure (pnpm --filter 'PKG^...' build, exit 0) and re-ran; all three then exit 0 with 0error TSlines.Changeset
Empty frontmatter —
.changeset/member-pins-8071-near-miss-four.md. Rule applied: AGENTS.md guards any file under a released package'ssrc/, and all four touched files are__tests__files undersrc/, so a declaration is owed; nothing published moves, so it declares no release. The gate's own text calls that "a pass, not a workaround".Out of scope, held to
covered. Untouched. Nothing found here argues for moving that fence: those blocks still have noComponentPropsMapentry, so there is still no authority to compare a member shape against, which is the open question the card records.no— this PR is test files + a ledger constant + an empty-frontmatter changeset.Held to on the requeue specifically
origin/maincame in by MERGE (72b6eec7), which is what the queue tests. Clean, no conflicts, so no generated file needed regenerating.Refs #8071left exactly as it was. The card must survive the merge.bulkActionDefsmember instead of crashing the selection bar #8741; nothing here reopens or re-files it.object-grid.bulkActionDefsthrows during render and takes the whole selection bar down #8730 class-level pins were not duplicated into this file.bulkActionDefsUnusableMember-8730.test.tsxalready covers every unusable member kind, survivor order and referential identity; this file keeps only the member-vocabulary half it was written for. Its own suite is run here as a neighbour-regression check (green).Generated by Claude Code