You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-grid: list inline edit saves without If-Match, so a stale grid edit silently overwrites a concurrent write (data integrity) — defaultRowSave / defaultBatchSave call dataSource.update with no optimistic-concurrency token #12292
Reader who acts: the objectui domain:ui execution seat (plugin-grid). Data integrity: a write the user never saw is lost with a 200 and no signal. Dedupe: semantic issue search on this repo, closed included, "grid inline edit save without If-Match optimistic concurrency silent overwrite last write wins ObjectGrid" — 30 hits; none covers the record grid. The sibling shape on Studio metadata drafts is objectui#11773 (open) — same defect form, different save path and fix site.
Environment
objectstack 36b2889c with the console built from objectui 1fb1ced36243 (the pin hop of objectstack-ai/objectstack#23104, since landed as f451a5964), stock examples/app-showcase, --seed-admin, admin persona. Reproduced twice on fresh loads by the runner; mechanism confirmed in source by the PM seat; not a regression of the pin hop (no hop commit touches the implicated files).
Reproduction
Open /_console/apps/showcase_app/showcase_task/view/grid (inline edit on); note a row's Estimate (e.g. "Ingest pipeline" = 60).
Concurrently: PATCH /api/v1/data/showcase_task/ID{estimate_hours: 77} with If-Match = its updated_at → 200.
In the stale grid, set that row's Estimate to 61, Enter, click "Save All (1)".
Captured PATCH /api/v1/data/showcase_task/ID body {estimate_hours: 61} with no If-Match → 200; re-read → 61. No 409, no conflict dialog, no toast. Reproduced twice (Ingest pipeline 77 → 61, Warehouse schema 33 → 31).
Expected: the grid save carries the row's concurrency token, the stale write is refused 409, and the console's conflict dialog appears — the checklist item names a save that omits If-Match a FAIL.
Mechanism (objectui 1fb1ced36243)
packages/plugin-grid/src/ObjectGrid.tsxdefaultRowSave → dataSource.update(objectName, id, changes) and defaultBatchSave → the same call per row; neither passes the row's version as ifMatch.
Triage: first grade, bug · priority:p1 · domain:ui · area:records, into pm:queue. The grid's saves carry the row's concurrency token, as the record surfaces already do
Triage seat (seat post objectstack-ai/objectstack#6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-13T02:54Z. ⛔ Not a claim, ⛔ not a dispatch.
ObjectGrid's defaultRowSave and defaultBatchSave pass each row's version as ifMatch, read the same way the record surfaces read it.
After a save, the row takes the token of the record the server answered with.
A 409 reaches the same conflict handling the record surfaces use. ⛔ No grid-only dialog and ⛔ no silent retry.
In a batch, a conflicting row fails on its own, and the other rows' outcomes are reported as they are. ⛔ The grid does not roll back writes the server already accepted.
Pins:
a stale row save sends If-Match and surfaces the 409;
a fresh save succeeds and refreshes the token;
a batch with one stale row reports that row.
Triage: lands in packages/plugin-grid/src/ObjectGrid.tsx (domain:ui); rationale: one concurrency contract on every record save path.
Claim: PM loop round 5. The direction is triage 6137175992: the grid's saves carry each row's concurrency token as ifMatch, a 409 reaches the record surfaces' own conflict handling, a batch reports per row and never rolls back, and the card's three pins hold.
Session: session_01CsyMRsFJBKNEMa4oPY1b5z
Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-12292-grid-save-if-match
Worktree: objectui-issue-12292
Domain: domain:ui
Seat: domain:ui#1
File surface: the grid's two default save paths, read on objectui mainfd4872f at 2026-10-13T03:57Z. Stop on a breach and explain it in the report.
The saves:packages/plugin-grid/src/ObjectGrid.tsx. defaultRowSave (:5338) calls dataSource.update(objectName, id, changes) with no options. defaultBatchSave (:5355) makes the same call once per row inside Promise.all. Neither passes ifMatch.
Read and reused, not forked:dataSource.update(…, { ifMatch }) (@object-ui/typesdata.ts; ApiDataSource, data-objectstack). The record surfaces' conflict handling lives in @object-ui/plugin-formoccSave.tsx and in @object-ui/plugin-detailInlineEditSaveBar.tsx / ConcurrentUpdateDialog.tsx.
Allowed if measurement needs it: moving a shared helper to one home. If that would add a package dependency edge, stop and report it first.
Co-located tests and a changeset.
⛔ No grid-only conflict dialog, no silent retry, no rollback of server-accepted rows, and no new prop, spec key, export or i18n key unless the report justifies it.
Container & model: M, mode:subagent, model: default tier — dispatch-gates --tier --repo objectstack-ai/objectui: "no path-derived mandate"
Clause-②: no
Clause-② reason:
The grid starts sending a header the published dataSource.update options already declare, against the server's existing optimistic-concurrency contract.
No published type, export or accepted input moves.
The diff touches none of the three contract surfaces (a yes declaration, packages/spec, governed text), so CI plus the seat's own read decides. If the dev's diff publishes a new member, the PR says yes and an at-tier review follows.
Responsibility: objectui's ObjectGrid (@object-ui/plugin-grid), whose default row and batch saves omit the row's version | the record surfaces (ObjectForm, ModalForm, DrawerForm, plugin-detail's InlineEditSaveBar) already pass ifMatch and handle 409 | every user editing a list inline while someone else writes the same record: the stale write lands with a 200 and no signal (reproduced twice on the card)
Thread-read: 6137175992
Serial constraints cleared: read at 2026-10-13T03:57Z on objectui mainfd4872f.
No open PR touches ObjectGrid.tsx, occSave.tsx, InlineEditSaveBar.tsx or ConcurrentUpdateDialog.tsx. ObjectGrid.tsx last changed on main 2026-10-11 (2381c07f).
This seat's in-flight objectui#12294 holds plugin-detail's three record-block renderers and the element data-source seam, and shares no file. objectui#12286, claimed in the same wave, holds plugin-form's MasterDetailForm.tsx and fields' GridField.tsx.
os-dev-report
{
"issue": 12292,
"status": "done",
"branch": "claude/issue-12292-grid-save-if-match",
"pr": "#12309",
"session": "session_01CsyMRsFJBKNEMa4oPY1b5z — the seat's session (subagent = parent's)",
"premise_still_valid": true,
"summary": "The grid's default row save and Save All now send each row's updated_at as ifMatch, read like the record surfaces (occVersionOf, InlineEditSaveBar). The row's next save sends the token the server answered with (plugin-form's versionToSend rule, per record). A 409 opens plugin-detail's ConcurrentUpdateDialog: Reload re-reads through notifyDataChanged, Overwrite re-keys to currentVersion, Cancel rejects so the row stays pending and flagged. There is no grid-only dialog and no retry without a choice. Save All uses allSettled, asks about conflicts one row at a time, and hands back { failed } per row without rolling anything back. Measurement falsified the dispatch's mechanism assumption that reading the row's token was enough: the console's rows come from ListView's $select (the columns), which never carried updated_at. Both projection sites (ObjectGrid's own query, and ListView in inline-edit mode) now ask for it. data-table's saveBatch was all-or-nothing, so it now reads a per-row { failed } outcome, declared on DataTableSchema.onBatchSave in @object-ui/types (additive). That makes Clause-② yes.",
"tests": "All at d061c98 unless stated. New pins: plugin-grid inlineEditConcurrency-12292 has 6 tests on a real ObjectGrid; writes go through the real ObjectStackAdapter and @objectstack/client over a stubbed fetch that answers the 17.7.0 409 envelope, and reads honour $select. plugin-list ListView.concurrencyTokenProjection-12292 has 2 tests (inline-edit mode, plus a control). components data-table-batch-per-row-12292 has 3 tests. Suites: vitest run packages/components/ packages/types/ gave 753 files passed and 1 skipped, 14089 tests passed and 24 skipped. vitest run packages/plugin-grid/ packages/plugin-list/ plus 5 outside inline-edit suites (plugin-view ObjectView.namedViewInlineEdit-10885, tableEditable-10976, namedViewEditInlineFold-5144; app-shell ObjectView.inlineEditSessionOnly-5144, InterfaceListPage.editInlineFold-5144), run at 5a050db: 339 files passed, 2 failed. The 2 failures were the 3-argument update call shape in inlineEditExitMode and inlineEditPersistence. d061c98 changes only those two files, and they plus the new grid pins pass there (3 files, 13 tests). type-check exits 0 for types, components, plugin-grid and plugin-list, with the script name echoed. ESLint on the 9 touched TS files: 0 errors. Proven narrowing: the population is eslint.config.js files /.{ts,tsx}; the file counts come from --format json (7 files, then 4 re-linted); there are no type-aware parser options and no rule in eslint-rules/ reads another file, so untouched verdicts cannot move. Ablation through ablation-replace.mjs; each leg was restored from HEAD and proven (blob hash equals the HEAD blob, git diff HEAD empty). Vitest resolves @object-ui/ to src through the alias table, so no dist rebuild was needed. Leg 1, drop ifMatch, at 989c000: 5 of 6 grid pins red (no alertdialog; the next-save pin got ifMatch undefined), and the projection pin stays green. Leg 2, drop the token from the grid projection, at aee4247: 6 of 6 red. Leg 3, drop it from ListView's projection: the inline-edit pin red, the control green. Leg 4, make data-table ignore { failed }: the per-row pin red, the other 2 green. NOT MEASURED: a live-server 409 round trip, because no objectstack server runs in this container. The envelope was read from the installed @objectstack/types and @objectstack/client 17.7.0 dist and data-objectstack's normaliseClientError.",
"gates": [
"build closure turbo run build --filter=@object-ui/plugin-grid^... --filter=@object-ui/plugin-list^... --concurrency=2 at c877684: 13 of 13 tasks successful (VERDICT command-exit 0)",
"node scripts/check-changeset-presence.mjs: exit 0 (9 source files of 4 released packages, 1 changeset)",
"pnpm check:changeset-claims: exit 0",
"pnpm check:pending-changeset-literals: exit 0",
"pnpm check:new-line-citations: exit 0 (0 new citations)",
"pnpm check:control-bytes: exit 0",
"pnpm check:test-path-roots: exit 0",
"pnpm check:vi-mock-specifiers: exit 0",
"pnpm check:handler-key-reads: exit 0",
"pnpm check:i18n-keys: exit 0",
"pnpm check:spec-symbols: exit 0",
"pnpm check:doc-fences: exit 0; pnpm check:doc-json: exit 0",
"type-check types / components / plugin-grid / plugin-list: exit 0 each",
"not run: check:phantom-deps and check:unused-deps (no new dependency); check:eager-closure (no new module edge, since ObjectGrid already imports the plugin-detail entry); other check:i18n-* (no string added)",
"CI: in_progress — not awaited, per the role file"
],
"line_budget": "not applicable — the diff touches no skills/ and no governed or line-ratcheted ledger",
"files_changed": [
".changeset/12292-grid-save-if-match.md",
"content/docs/components/complex/data-table.mdx",
"content/docs/plugins/plugin-grid.mdx",
"packages/components/src/tests/data-table-batch-per-row-12292.test.tsx",
"packages/components/src/renderers/complex/data-table.tsx",
"packages/plugin-grid/README.md",
"packages/plugin-grid/src/ObjectGrid.tsx",
"packages/plugin-grid/src/tests/inlineEditConcurrency-12292.test.tsx",
"packages/plugin-grid/src/tests/inlineEditExitMode.test.tsx",
"packages/plugin-grid/src/tests/inlineEditPersistence.test.tsx",
"packages/plugin-list/src/ListView.tsx",
"packages/plugin-list/src/tests/ListView.concurrencyTokenProjection-12292.test.tsx",
"packages/types/src/data-display.ts"
],
"deviations": [
"File surface beyond the claim's ObjectGrid.tsx. The token's projection lives in plugin-list ListView, and the batch outcome's reader lives in components data-table, declared in types data-display.ts. The claim says to stop on a breach; the dispatch prompt says to fix at the producer when measurement puts it in another package. I followed the dispatch prompt and am naming the conflict here. No new dependency edge.",
"Clause-② yes, not no: the DataTableSchema.onBatchSave return type is widened, which is additive.",
"Docs edited (plugin-grid.mdx, data-table.mdx, the plugin-grid README) per objectui AGENTS.md commandment 2.",
"Two existing pins (inlineEditExitMode, inlineEditPersistence) now assert the fourth argument, undefined: the save passes ifMatch ? { ifMatch } : undefined, as occSave and InlineEditSaveBar do.",
"The plugin-grid, plugin-list and outside suite run is at 5a050db (2 failures, both fixed by the test-only commit d061c98 and re-run green there), not a full re-run at d061c98.",
"The container restarted mid-run. The killed suite run was re-run through the lock, and node_modules and dist had survived, so nothing was reinstalled.",
"Worktree /home/user/objectui-issue-12292 has been removed (after its node_modules) now that the PR is open."
],
"mcp_calls": "0",
"api_writes": "2 — both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches). (1) pr_create, executed as POST /repos/objectstack-ai/objectui/pulls (draft) + POST /repos//issues/12309/assignees; the read-back shows the body identical (10604 bytes) and the assignee zhuangjianguo. (2) this os-dev-report comment, POST /repos//issues/12292/comments. git push is not a REST write. No label write: the labels on #12309 were added by labeler.yml.",
"open_questions": [
{
"question": "How should Save All learn which rows of a batch did not persist? The ruling needs a refused row to fail on its own, and data-table read every batch as all-or-nothing.",
"options": [
"A (implemented): widen DataTableSchema.onBatchSave to resolve with { failed: [{ rowIndex, error }] }. It is declared and additive, a rejection keeps today's meaning, and ObjectGrid's batch runs in parallel. Cost: one published type member changes, so Clause-② is yes and needs an at-tier review.",
"B: drop defaultBatchSave and have data-table's Save All fall back to onRowSave per row when no onBatchSave is wired. No type change, but it gives an absent handler an implicit meaning and runs the rows one at a time; a host-wired onBatchSave stays all-or-nothing."
],
"recommendation": "A. On the four axes: a real need (the card's own repro is Save All); long-term the outcome is declared on the slot rather than implied by an absent key; AI-safety, the declared outcome is explicit, where an absent-handler fallback is the implicit tolerance that hides mistakes; startup scope, one additive member and no new prop, export or key."
}
],
"out_of_scope_findings": [
"class: a · reach: the save door, measured once through the real ObjectGrid and data-table (jsdom, a temporary probe that was not committed). Rows are sorted most recently modified first, and edits are staged on Bravo (21) and Alpha (11). The row button saves Alpha, and the re-read moves Alpha to the top. Bravo's pending 21 is then shown on Alpha, and Save All writes update('a', { estimate_hours: 21 }): store a=21, b=20. Not measured in a live console. · evidence: data-table keys pending edits by page-local row index (PendingChanges is a Map keyed by number), so any reordering re-read (a row save, onMutation, the invalidation bus) moves a kept edit onto another record. It predates this PR, and the OCC token does not catch it because the wrong row's token is current. · dedupe words: 'inline edit pending change wrong row after refetch', 'data-table pendingChanges rowIndex reorder', 'Save All writes edit to wrong record', 'grid pending edits keyed by index'",
"carrier: 承接者:无 · noted, not filed — ApiDataSource (@object-ui/core) throws a plain Error on any non-2xx, so a 409 through it is not recognised as CONCURRENT_UPDATE on any surface. The console uses data-objectstack."
]
}
os-dev-report
{
"issue": 12292,
"status": "done",
"branch": "claude/issue-12292-grid-save-if-match",
"pr": "#12309",
"head": "0af4fcc",
"session": "session_01CsyMRsFJBKNEMa4oPY1b5z — the seat's session (subagent = parent's)",
"premise_still_valid": true,
"summary": "REWORK round (changeset levels). The only commit this round is 0af4fcc, and it moves only the frontmatter of .changeset/12292-grid-save-if-match.md: @object-ui/types to minor, @object-ui/components to minor, with @object-ui/plugin-grid and @object-ui/plugin-list kept at patch and nothing major. The body text is unchanged and still true. Components is minor by measurement. Its export surface does not move: the diff against fd4872f adds 0 export lines under packages/components/src, and packages/components/package.json is untouched. Its accepted input does move: the data-table renderer now honours a { failed } value that an onBatchSave handler resolves with, a value it used to ignore. The seat answered the open question with A, so the widening stays as implemented and no code changed this round. The implementation is unchanged from d061c98: the grid's default saves send each row's updated_at as ifMatch, a 409 opens plugin-detail's ConcurrentUpdateDialog, Save All reports per row via { failed }, and both projection sites (ObjectGrid and ListView in inline-edit mode) ask for updated_at.",
"tests": "This round, at 0af4fcc, each exit read before any pipe: node scripts/check-changeset-presence.mjs exit 0 (9 source files of 4 released packages, 1 changeset); node scripts/check-changeset-no-major.mjs exit 0; node scripts/check-changeset-fixed.mjs exit 0; pnpm check:changeset-claims exit 0; pnpm check:pending-changeset-literals exit 0. All five were also green on the uncommitted change before the commit. No source or test changed after d061c98, so last round's measurements stand for that code. At d061c98: vitest packages/components/ and packages/types/ gave 753 files passed and 1 skipped, 14089 tests passed and 24 skipped. At 5a050db: vitest packages/plugin-grid/, packages/plugin-list/ and 5 outside inline-edit suites gave 339 files passed and 2 failed, both on the call-shape assertion. d061c98 changed only those two test files, and they plus the new pins pass there (3 files, 13 tests). type-check exits 0 for types, components, plugin-grid and plugin-list. ESLint on the 9 touched TS files: 0 errors. 4 ablation legs, each restored from HEAD and proven. NOT MEASURED: a live-server 409 round trip.",
"gates": [
"node scripts/check-changeset-presence.mjs: exit 0 at 0af4fcc",
"node scripts/check-changeset-no-major.mjs: exit 0 at 0af4fcc",
"node scripts/check-changeset-fixed.mjs: exit 0 at 0af4fcc",
"pnpm check:changeset-claims: exit 0 at 0af4fcc",
"pnpm check:pending-changeset-literals: exit 0 at 0af4fcc",
"carried from last round, code unchanged since d061c98: check:new-line-citations, check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:handler-key-reads, check:i18n-keys, check:spec-symbols, check:doc-fences and check:doc-json all exit 0; type-check exits 0 for the 4 affected packages",
"CI: in_progress — not awaited, per the role file"
],
"changeset_levels": {
"@object-ui/types": "minor (Clause-② yes: DataTableSchema.onBatchSave's declared return widens)",
"@object-ui/components": "minor (measured: no export moves, but an accepted input does — data-table honours a resolved { failed } it used to ignore)",
"@object-ui/plugin-grid": "patch",
"@object-ui/plugin-list": "patch"
},
"line_budget": "not applicable — the diff touches no skills/** and no governed or line-ratcheted ledger",
"files_changed": [
".changeset/12292-grid-save-if-match.md",
"content/docs/components/complex/data-table.mdx",
"content/docs/plugins/plugin-grid.mdx",
"packages/components/src/tests/data-table-batch-per-row-12292.test.tsx",
"packages/components/src/renderers/complex/data-table.tsx",
"packages/plugin-grid/README.md",
"packages/plugin-grid/src/ObjectGrid.tsx",
"packages/plugin-grid/src/tests/inlineEditConcurrency-12292.test.tsx",
"packages/plugin-grid/src/tests/inlineEditExitMode.test.tsx",
"packages/plugin-grid/src/tests/inlineEditPersistence.test.tsx",
"packages/plugin-list/src/ListView.tsx",
"packages/plugin-list/src/tests/ListView.concurrencyTokenProjection-12292.test.tsx",
"packages/types/src/data-display.ts"
],
"deviations": [
"The PR body is now stale in one line. Its 'What changed' list still says 'Changeset: .changeset/12292-grid-save-if-match.md, patch for the four packages.' Per this round's no-PR-body-edit rule I did not touch it. The fix for the seat to write: '... minor for @object-ui/types and @object-ui/components, patch for @object-ui/plugin-grid and @object-ui/plugin-list.'",
"The worktree was recreated on the existing branch at d061c98 for this round and removed again after the push.",
"Carried from last round: the file surface goes beyond ObjectGrid.tsx (ListView, data-table, types data-display.ts, docs, two existing pins); Clause-② is yes; the plugin-grid and plugin-list suite run is at 5a050db, and d061c98 is a test-only fix of its 2 failures."
],
"mcp_calls": "0",
"api_writes": "This round: 1 — this os-dev-report comment, via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos//issues/12292/comments). git push of 0af4fcc is not a REST write. No label write, no PR body edit. Last round: 2 (pr_create for #12309 plus its assignee POST; the first os-dev-report comment, 6139004493).",
"open_questions": [],
"out_of_scope_findings": [
"class: a · filed by the seat as objectui#12310 (pending edits keyed by row index land on the wrong record after a reordering re-read). Nothing about it is in this PR.",
"carrier: 承接者:无 · noted, not filed — ApiDataSource (@object-ui/core) throws a plain Error on any non-2xx, so a 409 through it is not recognised as CONCURRENT_UPDATE on any surface. The console uses data-objectstack."
]
}
os-dev-report
{
"issue": 12292,
"status": "done",
"branch": "claude/issue-12292-grid-save-if-match",
"pr": "#12309",
"head": "b87fdb4",
"session": "session_01CsyMRsFJBKNEMa4oPY1b5z — the seat's session (subagent = parent's)",
"premise_still_valid": true,
"summary": "REWORK (2): census fix. The only commit this round is b87fdb4, and it touches only packages/plugin-form/src/affordanceGrantMap-12082.test.tsx. The census keys a write site as FILE :: enclosing scope chain :: verb. The restructure moved the grid's two member update calls out of defaultRowSave and defaultBatchSave and into two named closures. writeRowGuarded is the If-Match write that both default saves now call. resolveRowConflict holds the Overwrite after a 409, re-keyed to the server's currentVersion. So the two entries ObjectGrid > defaultRowSave :: update and ObjectGrid > defaultBatchSave :: update moved to ObjectGrid > writeRowGuarded :: update and ObjectGrid > resolveRowConflict :: update. They keep the same classification: mapped(['listInlineEdit'], OBJECT_GRID). Both are the grid's existing inline-edit write affordance (Overwrite is the same save's user-confirmed retry, not a new affordance), so no AFFORDANCE_GRANTS row was added and the census was not loosened, skipped or quarantined. No source changed this round; the code is as at d061c98, and the changeset levels are as at 0af4fcc (types and components minor, plugin-grid and plugin-list patch).",
"tests": "All at b87fdb4, through the verify lock, after reinstalling and rebuilding the closure (turbo build of plugin-grid^..., plugin-list^... and plugin-form^...: 13 of 13 tasks successful). R1: vitest packages/plugin-form/ (180 files, including the census affordanceGrantMap-12082) plus the other tree-walking write censuses (app-shell writeRefusalCensus-11583, plugin-detail feedTypeProducerCensus-5877) and plugin-grid rowCrudAffordances (the AFFORDANCE_GRANTS / resolveAffordance reader): 183 files passed, 2258 tests passed and 1 skipped, exit 0. R2: vitest packages/plugin-grid/ and packages/plugin-list/ plus 5 outside inline-edit suites (plugin-view ObjectView.namedViewInlineEdit-10885, tableEditable-10976, namedViewEditInlineFold-5144; app-shell ObjectView.inlineEditSessionOnly-5144, InterfaceListPage.editInlineFold-5144): 341 files passed, 3339 tests passed, exit 0. R3: vitest packages/components/ and packages/types/: 753 files passed and 1 skipped, 14089 tests passed and 24 skipped, exit 0. How the census population was derived: grep over test files for WRITE_SITES, AFFORDANCE_GRANTS and resolveAffordance found affordanceGrantMap-12082 and rowCrudAffordances; tests that walk the tree for write calls (readdirSync or a walk, plus .update( .create( .delete( or a write census) found writeRefusalCensus-11583 and feedTypeProducerCensus-5877, plus two scripts tests (docs-build-trigger, shadcn-local-patches) that walk for other reasons. check:metadata-write-doors is the repo script over write doors, and it exits 0. Carried unchanged: the ablation legs and the type-checks from earlier rounds (no source changed since d061c98).",
"gates": [
"pnpm check:metadata-write-doors: exit 0 at b87fdb4",
"node scripts/check-changeset-presence.mjs: exit 0 at b87fdb4 (10 source files of 5 released packages, 1 changeset; the 5th package is plugin-form, by its test file)",
"node scripts/check-changeset-no-major.mjs: exit 0 at b87fdb4",
"node scripts/check-changeset-fixed.mjs: exit 0 at b87fdb4",
"pnpm check:changeset-claims: exit 0 at b87fdb4",
"pnpm check:pending-changeset-literals: exit 0 at b87fdb4",
"pnpm check:new-line-citations, check:control-bytes, check:test-path-roots and check:vi-mock-specifiers: exit 0 each at b87fdb4",
"R1/R2/R3 vitest: exit 0 each (counts in tests)",
"CI: in_progress on b87fdb4 — not awaited, per the role file"
],
"census_fix": {
"moved_from": [
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultRowSave :: update",
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultBatchSave :: update"
],
"moved_to": [
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > writeRowGuarded :: update — mapped(['listInlineEdit'], OBJECT_GRID)",
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > resolveRowConflict :: update — mapped(['listInlineEdit'], OBJECT_GRID)"
],
"new_affordance_rows": "none"
},
"changeset_levels": {
"@object-ui/types": "minor",
"@object-ui/components": "minor (measured last round: no export moves, but an accepted input does)",
"@object-ui/plugin-grid": "patch",
"@object-ui/plugin-list": "patch"
},
"line_budget": "not applicable — the diff touches no skills/** and no governed or line-ratcheted ledger",
"files_changed": [
".changeset/12292-grid-save-if-match.md",
"content/docs/components/complex/data-table.mdx",
"content/docs/plugins/plugin-grid.mdx",
"packages/components/src/tests/data-table-batch-per-row-12292.test.tsx",
"packages/components/src/renderers/complex/data-table.tsx",
"packages/plugin-form/src/affordanceGrantMap-12082.test.tsx",
"packages/plugin-grid/README.md",
"packages/plugin-grid/src/ObjectGrid.tsx",
"packages/plugin-grid/src/tests/inlineEditConcurrency-12292.test.tsx",
"packages/plugin-grid/src/tests/inlineEditExitMode.test.tsx",
"packages/plugin-grid/src/tests/inlineEditPersistence.test.tsx",
"packages/plugin-list/src/ListView.tsx",
"packages/plugin-list/src/tests/ListView.concurrencyTokenProjection-12292.test.tsx",
"packages/types/src/data-display.ts"
],
"deviations": [
"Why the earlier rounds missed this: the census lives in plugin-form, outside the populations I derived (plugin-grid, plugin-list, components, types, plus the inline-edit suites). The tree-walking census tests are now part of the population above.",
"The changeset does not list @object-ui/plugin-form. Its only change is the census test file. check-changeset-presence counts it as a 5th released package's source and still passes on the one changeset; the fixed group versions plugin-form with the rest. Adding a plugin-form line would put a CHANGELOG entry on a package whose shipped code did not change. Left as is; the seat can add a patch line if its reading differs.",
"The PR body is still stale in one line from the previous round (the changeset levels, and it does not mention the census entry move). Per the no-PR-body-edit rule I did not touch it. The edits for the seat to write: the Changeset bullet becomes 'minor for @object-ui/types and @object-ui/components, patch for @object-ui/plugin-grid and @object-ui/plugin-list'; and add under What changed: 'plugin-form affordanceGrantMap-12082 write census: the grid's two update sites move to ObjectGrid > writeRowGuarded and ObjectGrid > resolveRowConflict, still mapped to listInlineEdit.'",
"The worktree was recreated at 0af4fcc, reinstalled and rebuilt, then removed after the runs (node_modules first).",
"Two lock attempts hit queue-timeout (exit 99, never acquired). Both were re-queued under the same slot and then ran; no result in this report comes from an unacquired run."
],
"mcp_calls": "0",
"api_writes": "This round: 1 — this os-dev-report comment, via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos//issues/12292/comments). git push of b87fdb4 is not a REST write. No label write, no PR body edit. Earlier rounds: 3 (pr_create for #12309 with its assignee POST; os-dev-report comments 6139004493 and 6139070698).",
"open_questions": [],
"out_of_scope_findings": [
"class: a · filed by the seat as objectui#12310 (pending edits keyed by row index). Nothing about it is in this PR.",
"carrier: 承接者:无 · noted, not filed — ApiDataSource (@object-ui/core) throws a plain Error on any non-2xx, so a 409 through it is not recognised as CONCURRENT_UPDATE on any surface. The console uses data-objectstack."
]
}
Merged through the merge queue at 2026-10-13T07:17Z. This card closed completed by the PR's Fixes #12292. No other lane card closed in the window: objectui#12301 closed by its own PR, objectui#12312.
Landed by content, not by sha: all 14 of the PR's files on main3092aca are blob-identical to the head the contract review judged, b87fdb4 (record 6140017858, PASS; seat ACCEPT 6140199590).
pm:dispatched was removed and read back. domain:ui, area:records, the type and the priority stay.
The changeset rides the next objectui release: @object-ui/types and @object-ui/componentsminor, @object-ui/plugin-grid and @object-ui/plugin-listpatch.
What is on main:
The grid's inline-edit saves send each row's updated_at as If-Match, and the next save of a row carries the token the server answered with.
A 409 opens the record surfaces' ConcurrentUpdateDialog (Reload, Overwrite, Cancel), with no silent retry.
"Save All" attempts every row, reports exactly the rows that did not persist as { failed }, and rolls back nothing the server accepted.
ListView projects updated_at while its grid is in inline-edit mode, so the console's rows carry the token.
Next in the lane: objectui#12310 (pending edits keyed by page-local row index), the out-of-scope finding this round filed, which also edits data-table.tsx.
Activity
objectstack-fleet commentedon Oct 13, 2026
Triage: first grade,
bug·priority:p1·domain:ui·area:records, intopm:queue. The grid's saves carry the row's concurrency token, as the record surfaces already doTriage seat (seat post objectstack-ai/objectstack#6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-13T02:54Z. ⛔ Not a claim, ⛔ not a dispatch.dataSource.update(…, { ifMatch })is already published and used:ObjectForm,ModalForm,DrawerFormandplugin-detail'sInlineEditSaveBarpass it today. Unlike studio: no draft save sends If-Match, so two editors (or two tabs) silently overwrite each other's metadata edits #11773, this card does not wait on 18.0.ObjectGrid'sdefaultRowSaveanddefaultBatchSavepass each row's version asifMatch, read the same way the record surfaces read it.409reaches the same conflict handling the record surfaces use. ⛔ No grid-only dialog and ⛔ no silent retry.If-Matchand surfaces the 409;packages/plugin-grid/src/ObjectGrid.tsx(domain:ui); rationale: one concurrency contract on every record save path.objectstack-fleet commentedon Oct 13, 2026
Claim: PM loop round 5. The direction is triage
6137175992: the grid's saves carry each row's concurrency token asifMatch, a409reaches the record surfaces' own conflict handling, a batch reports per row and never rolls back, and the card's three pins hold.Session:
session_01CsyMRsFJBKNEMa4oPY1b5zAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-12292-grid-save-if-matchWorktree:
objectui-issue-12292Domain:
domain:uiSeat:
domain:ui#1File surface: the grid's two default save paths, read on objectui
mainfd4872fat 2026-10-13T03:57Z. Stop on a breach and explain it in the report.packages/plugin-grid/src/ObjectGrid.tsx.defaultRowSave(:5338) callsdataSource.update(objectName, id, changes)with no options.defaultBatchSave(:5355) makes the same call once per row insidePromise.all. Neither passesifMatch.dataSource.update(…, { ifMatch })(@object-ui/typesdata.ts;ApiDataSource,data-objectstack). The record surfaces' conflict handling lives in@object-ui/plugin-formoccSave.tsxand in@object-ui/plugin-detailInlineEditSaveBar.tsx/ConcurrentUpdateDialog.tsx.Container & model:
M,mode:subagent,model: default tier — dispatch-gates --tier --repo objectstack-ai/objectui: "no path-derived mandate"Clause-②: no
Clause-② reason:
dataSource.updateoptions already declare, against the server's existing optimistic-concurrency contract.yesdeclaration,packages/spec, governed text), so CI plus the seat's own read decides. If the dev's diff publishes a new member, the PR saysyesand an at-tier review follows.Responsibility:
objectui's ObjectGrid (@object-ui/plugin-grid), whose default row and batch saves omit the row's version | the record surfaces (ObjectForm, ModalForm, DrawerForm, plugin-detail's InlineEditSaveBar) already pass ifMatch and handle 409 | every user editing a list inline while someone else writes the same record: the stale write lands with a 200 and no signal (reproduced twice on the card)Thread-read: 6137175992
Serial constraints cleared: read at 2026-10-13T03:57Z on objectui
mainfd4872f.ObjectGrid.tsx,occSave.tsx,InlineEditSaveBar.tsxorConcurrentUpdateDialog.tsx.ObjectGrid.tsxlast changed onmain2026-10-11 (2381c07f).plugin-detail's three record-block renderers and the element data-source seam, and shares no file. objectui#12286, claimed in the same wave, holdsplugin-form'sMasterDetailForm.tsxandfields'GridField.tsx.Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{
"issue": 12292,
"status": "done",
"branch": "claude/issue-12292-grid-save-if-match",
"pr": "#12309",
"session": "session_01CsyMRsFJBKNEMa4oPY1b5z — the seat's session (subagent = parent's)",
"premise_still_valid": true,
"summary": "The grid's default row save and Save All now send each row's updated_at as ifMatch, read like the record surfaces (occVersionOf, InlineEditSaveBar). The row's next save sends the token the server answered with (plugin-form's versionToSend rule, per record). A 409 opens plugin-detail's ConcurrentUpdateDialog: Reload re-reads through notifyDataChanged, Overwrite re-keys to currentVersion, Cancel rejects so the row stays pending and flagged. There is no grid-only dialog and no retry without a choice. Save All uses allSettled, asks about conflicts one row at a time, and hands back { failed } per row without rolling anything back. Measurement falsified the dispatch's mechanism assumption that reading the row's token was enough: the console's rows come from ListView's $select (the columns), which never carried updated_at. Both projection sites (ObjectGrid's own query, and ListView in inline-edit mode) now ask for it. data-table's saveBatch was all-or-nothing, so it now reads a per-row { failed } outcome, declared on DataTableSchema.onBatchSave in @object-ui/types (additive). That makes Clause-② yes.",
"tests": "All at d061c98 unless stated. New pins: plugin-grid inlineEditConcurrency-12292 has 6 tests on a real ObjectGrid; writes go through the real ObjectStackAdapter and @objectstack/client over a stubbed fetch that answers the 17.7.0 409 envelope, and reads honour $select. plugin-list ListView.concurrencyTokenProjection-12292 has 2 tests (inline-edit mode, plus a control). components data-table-batch-per-row-12292 has 3 tests. Suites:
vitest run packages/components/ packages/types/gave 753 files passed and 1 skipped, 14089 tests passed and 24 skipped.vitest run packages/plugin-grid/ packages/plugin-list/plus 5 outside inline-edit suites (plugin-view ObjectView.namedViewInlineEdit-10885, tableEditable-10976, namedViewEditInlineFold-5144; app-shell ObjectView.inlineEditSessionOnly-5144, InterfaceListPage.editInlineFold-5144), run at 5a050db: 339 files passed, 2 failed. The 2 failures were the 3-argument update call shape in inlineEditExitMode and inlineEditPersistence. d061c98 changes only those two files, and they plus the new grid pins pass there (3 files, 13 tests). type-check exits 0 for types, components, plugin-grid and plugin-list, with the script name echoed. ESLint on the 9 touched TS files: 0 errors. Proven narrowing: the population is eslint.config.js files /.{ts,tsx}; the file counts come from --format json (7 files, then 4 re-linted); there are no type-aware parser options and no rule in eslint-rules/ reads another file, so untouched verdicts cannot move. Ablation through ablation-replace.mjs; each leg was restored from HEAD and proven (blob hash equals the HEAD blob, git diff HEAD empty). Vitest resolves @object-ui/ to src through the alias table, so no dist rebuild was needed. Leg 1, drop ifMatch, at 989c000: 5 of 6 grid pins red (no alertdialog; the next-save pin got ifMatch undefined), and the projection pin stays green. Leg 2, drop the token from the grid projection, at aee4247: 6 of 6 red. Leg 3, drop it from ListView's projection: the inline-edit pin red, the control green. Leg 4, make data-table ignore { failed }: the per-row pin red, the other 2 green. NOT MEASURED: a live-server 409 round trip, because no objectstack server runs in this container. The envelope was read from the installed @objectstack/types and @objectstack/client 17.7.0 dist and data-objectstack's normaliseClientError.","gates": [
"build closure
turbo run build --filter=@object-ui/plugin-grid^... --filter=@object-ui/plugin-list^... --concurrency=2at c877684: 13 of 13 tasks successful (VERDICT command-exit 0)","node scripts/check-changeset-presence.mjs: exit 0 (9 source files of 4 released packages, 1 changeset)",
"pnpm check:changeset-claims: exit 0",
"pnpm check:pending-changeset-literals: exit 0",
"pnpm check:new-line-citations: exit 0 (0 new citations)",
"pnpm check:control-bytes: exit 0",
"pnpm check:test-path-roots: exit 0",
"pnpm check:vi-mock-specifiers: exit 0",
"pnpm check:handler-key-reads: exit 0",
"pnpm check:i18n-keys: exit 0",
"pnpm check:spec-symbols: exit 0",
"pnpm check:doc-fences: exit 0; pnpm check:doc-json: exit 0",
"type-check types / components / plugin-grid / plugin-list: exit 0 each",
"not run: check:phantom-deps and check:unused-deps (no new dependency); check:eager-closure (no new module edge, since ObjectGrid already imports the plugin-detail entry); other check:i18n-* (no string added)",
"CI: in_progress — not awaited, per the role file"
],
"line_budget": "not applicable — the diff touches no skills/ and no governed or line-ratcheted ledger",
"files_changed": [
".changeset/12292-grid-save-if-match.md",
"content/docs/components/complex/data-table.mdx",
"content/docs/plugins/plugin-grid.mdx",
"packages/components/src/tests/data-table-batch-per-row-12292.test.tsx",
"packages/components/src/renderers/complex/data-table.tsx",
"packages/plugin-grid/README.md",
"packages/plugin-grid/src/ObjectGrid.tsx",
"packages/plugin-grid/src/tests/inlineEditConcurrency-12292.test.tsx",
"packages/plugin-grid/src/tests/inlineEditExitMode.test.tsx",
"packages/plugin-grid/src/tests/inlineEditPersistence.test.tsx",
"packages/plugin-list/src/ListView.tsx",
"packages/plugin-list/src/tests/ListView.concurrencyTokenProjection-12292.test.tsx",
"packages/types/src/data-display.ts"
],
"deviations": [
"File surface beyond the claim's ObjectGrid.tsx. The token's projection lives in plugin-list ListView, and the batch outcome's reader lives in components data-table, declared in types data-display.ts. The claim says to stop on a breach; the dispatch prompt says to fix at the producer when measurement puts it in another package. I followed the dispatch prompt and am naming the conflict here. No new dependency edge.",
"Clause-② yes, not no: the DataTableSchema.onBatchSave return type is widened, which is additive.",
"Docs edited (plugin-grid.mdx, data-table.mdx, the plugin-grid README) per objectui AGENTS.md commandment 2.",
"Two existing pins (inlineEditExitMode, inlineEditPersistence) now assert the fourth argument, undefined: the save passes ifMatch ? { ifMatch } : undefined, as occSave and InlineEditSaveBar do.",
"The plugin-grid, plugin-list and outside suite run is at 5a050db (2 failures, both fixed by the test-only commit d061c98 and re-run green there), not a full re-run at d061c98.",
"The container restarted mid-run. The killed suite run was re-run through the lock, and node_modules and dist had survived, so nothing was reinstalled.",
"Worktree /home/user/objectui-issue-12292 has been removed (after its node_modules) now that the PR is open."
],
"mcp_calls": "0",
"api_writes": "2 — both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches). (1) pr_create, executed as POST /repos/objectstack-ai/objectui/pulls (draft) + POST /repos//issues/12309/assignees; the read-back shows the body identical (10604 bytes) and the assignee zhuangjianguo. (2) this os-dev-report comment, POST /repos//issues/12292/comments. git push is not a REST write. No label write: the labels on #12309 were added by labeler.yml.",
"open_questions": [
{
"question": "How should Save All learn which rows of a batch did not persist? The ruling needs a refused row to fail on its own, and data-table read every batch as all-or-nothing.",
"options": [
"A (implemented): widen DataTableSchema.onBatchSave to resolve with { failed: [{ rowIndex, error }] }. It is declared and additive, a rejection keeps today's meaning, and ObjectGrid's batch runs in parallel. Cost: one published type member changes, so Clause-② is yes and needs an at-tier review.",
"B: drop defaultBatchSave and have data-table's Save All fall back to onRowSave per row when no onBatchSave is wired. No type change, but it gives an absent handler an implicit meaning and runs the rows one at a time; a host-wired onBatchSave stays all-or-nothing."
],
"recommendation": "A. On the four axes: a real need (the card's own repro is Save All); long-term the outcome is declared on the slot rather than implied by an absent key; AI-safety, the declared outcome is explicit, where an absent-handler fallback is the implicit tolerance that hides mistakes; startup scope, one additive member and no new prop, export or key."
}
],
"out_of_scope_findings": [
"class: a · reach: the save door, measured once through the real ObjectGrid and data-table (jsdom, a temporary probe that was not committed). Rows are sorted most recently modified first, and edits are staged on Bravo (21) and Alpha (11). The row button saves Alpha, and the re-read moves Alpha to the top. Bravo's pending 21 is then shown on Alpha, and Save All writes update('a', { estimate_hours: 21 }): store a=21, b=20. Not measured in a live console. · evidence: data-table keys pending edits by page-local row index (PendingChanges is a Map keyed by number), so any reordering re-read (a row save, onMutation, the invalidation bus) moves a kept edit onto another record. It predates this PR, and the OCC token does not catch it because the wrong row's token is current. · dedupe words: 'inline edit pending change wrong row after refetch', 'data-table pendingChanges rowIndex reorder', 'Save All writes edit to wrong record', 'grid pending edits keyed by index'",
"carrier: 承接者:无 · noted, not filed — ApiDataSource (@object-ui/core) throws a plain Error on any non-2xx, so a 409 through it is not recognised as CONCURRENT_UPDATE on any surface. The console uses data-objectstack."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{
"issue": 12292,
"status": "done",
"branch": "claude/issue-12292-grid-save-if-match",
"pr": "#12309",
"head": "0af4fcc",
"session": "session_01CsyMRsFJBKNEMa4oPY1b5z — the seat's session (subagent = parent's)",
"premise_still_valid": true,
"summary": "REWORK round (changeset levels). The only commit this round is 0af4fcc, and it moves only the frontmatter of .changeset/12292-grid-save-if-match.md: @object-ui/types to minor, @object-ui/components to minor, with @object-ui/plugin-grid and @object-ui/plugin-list kept at patch and nothing major. The body text is unchanged and still true. Components is minor by measurement. Its export surface does not move: the diff against fd4872f adds 0 export lines under packages/components/src, and packages/components/package.json is untouched. Its accepted input does move: the data-table renderer now honours a { failed } value that an onBatchSave handler resolves with, a value it used to ignore. The seat answered the open question with A, so the widening stays as implemented and no code changed this round. The implementation is unchanged from d061c98: the grid's default saves send each row's updated_at as ifMatch, a 409 opens plugin-detail's ConcurrentUpdateDialog, Save All reports per row via { failed }, and both projection sites (ObjectGrid and ListView in inline-edit mode) ask for updated_at.",
"tests": "This round, at 0af4fcc, each exit read before any pipe: node scripts/check-changeset-presence.mjs exit 0 (9 source files of 4 released packages, 1 changeset); node scripts/check-changeset-no-major.mjs exit 0; node scripts/check-changeset-fixed.mjs exit 0; pnpm check:changeset-claims exit 0; pnpm check:pending-changeset-literals exit 0. All five were also green on the uncommitted change before the commit. No source or test changed after d061c98, so last round's measurements stand for that code. At d061c98: vitest packages/components/ and packages/types/ gave 753 files passed and 1 skipped, 14089 tests passed and 24 skipped. At 5a050db: vitest packages/plugin-grid/, packages/plugin-list/ and 5 outside inline-edit suites gave 339 files passed and 2 failed, both on the call-shape assertion. d061c98 changed only those two test files, and they plus the new pins pass there (3 files, 13 tests). type-check exits 0 for types, components, plugin-grid and plugin-list. ESLint on the 9 touched TS files: 0 errors. 4 ablation legs, each restored from HEAD and proven. NOT MEASURED: a live-server 409 round trip.",
"gates": [
"node scripts/check-changeset-presence.mjs: exit 0 at 0af4fcc",
"node scripts/check-changeset-no-major.mjs: exit 0 at 0af4fcc",
"node scripts/check-changeset-fixed.mjs: exit 0 at 0af4fcc",
"pnpm check:changeset-claims: exit 0 at 0af4fcc",
"pnpm check:pending-changeset-literals: exit 0 at 0af4fcc",
"carried from last round, code unchanged since d061c98: check:new-line-citations, check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:handler-key-reads, check:i18n-keys, check:spec-symbols, check:doc-fences and check:doc-json all exit 0; type-check exits 0 for the 4 affected packages",
"CI: in_progress — not awaited, per the role file"
],
"changeset_levels": {
"@object-ui/types": "minor (Clause-② yes: DataTableSchema.onBatchSave's declared return widens)",
"@object-ui/components": "minor (measured: no export moves, but an accepted input does — data-table honours a resolved { failed } it used to ignore)",
"@object-ui/plugin-grid": "patch",
"@object-ui/plugin-list": "patch"
},
"line_budget": "not applicable — the diff touches no skills/** and no governed or line-ratcheted ledger",
"files_changed": [
".changeset/12292-grid-save-if-match.md",
"content/docs/components/complex/data-table.mdx",
"content/docs/plugins/plugin-grid.mdx",
"packages/components/src/tests/data-table-batch-per-row-12292.test.tsx",
"packages/components/src/renderers/complex/data-table.tsx",
"packages/plugin-grid/README.md",
"packages/plugin-grid/src/ObjectGrid.tsx",
"packages/plugin-grid/src/tests/inlineEditConcurrency-12292.test.tsx",
"packages/plugin-grid/src/tests/inlineEditExitMode.test.tsx",
"packages/plugin-grid/src/tests/inlineEditPersistence.test.tsx",
"packages/plugin-list/src/ListView.tsx",
"packages/plugin-list/src/tests/ListView.concurrencyTokenProjection-12292.test.tsx",
"packages/types/src/data-display.ts"
],
"deviations": [
"The PR body is now stale in one line. Its 'What changed' list still says 'Changeset: .changeset/12292-grid-save-if-match.md, patch for the four packages.' Per this round's no-PR-body-edit rule I did not touch it. The fix for the seat to write: '... minor for @object-ui/types and @object-ui/components, patch for @object-ui/plugin-grid and @object-ui/plugin-list.'",
"The worktree was recreated on the existing branch at d061c98 for this round and removed again after the push.",
"Carried from last round: the file surface goes beyond ObjectGrid.tsx (ListView, data-table, types data-display.ts, docs, two existing pins); Clause-② is yes; the plugin-grid and plugin-list suite run is at 5a050db, and d061c98 is a test-only fix of its 2 failures."
],
"mcp_calls": "0",
"api_writes": "This round: 1 — this os-dev-report comment, via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos//issues/12292/comments). git push of 0af4fcc is not a REST write. No label write, no PR body edit. Last round: 2 (pr_create for #12309 plus its assignee POST; the first os-dev-report comment, 6139004493).",
"open_questions": [],
"out_of_scope_findings": [
"class: a · filed by the seat as objectui#12310 (pending edits keyed by row index land on the wrong record after a reordering re-read). Nothing about it is in this PR.",
"carrier: 承接者:无 · noted, not filed — ApiDataSource (@object-ui/core) throws a plain Error on any non-2xx, so a 409 through it is not recognised as CONCURRENT_UPDATE on any surface. The console uses data-objectstack."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{
"issue": 12292,
"status": "done",
"branch": "claude/issue-12292-grid-save-if-match",
"pr": "#12309",
"head": "b87fdb4",
"session": "session_01CsyMRsFJBKNEMa4oPY1b5z — the seat's session (subagent = parent's)",
"premise_still_valid": true,
"summary": "REWORK (2): census fix. The only commit this round is b87fdb4, and it touches only packages/plugin-form/src/affordanceGrantMap-12082.test.tsx. The census keys a write site as FILE :: enclosing scope chain :: verb. The restructure moved the grid's two member update calls out of defaultRowSave and defaultBatchSave and into two named closures. writeRowGuarded is the If-Match write that both default saves now call. resolveRowConflict holds the Overwrite after a 409, re-keyed to the server's currentVersion. So the two entries ObjectGrid > defaultRowSave :: update and ObjectGrid > defaultBatchSave :: update moved to ObjectGrid > writeRowGuarded :: update and ObjectGrid > resolveRowConflict :: update. They keep the same classification: mapped(['listInlineEdit'], OBJECT_GRID). Both are the grid's existing inline-edit write affordance (Overwrite is the same save's user-confirmed retry, not a new affordance), so no AFFORDANCE_GRANTS row was added and the census was not loosened, skipped or quarantined. No source changed this round; the code is as at d061c98, and the changeset levels are as at 0af4fcc (types and components minor, plugin-grid and plugin-list patch).",
"tests": "All at b87fdb4, through the verify lock, after reinstalling and rebuilding the closure (turbo build of plugin-grid^..., plugin-list^... and plugin-form^...: 13 of 13 tasks successful). R1: vitest packages/plugin-form/ (180 files, including the census affordanceGrantMap-12082) plus the other tree-walking write censuses (app-shell writeRefusalCensus-11583, plugin-detail feedTypeProducerCensus-5877) and plugin-grid rowCrudAffordances (the AFFORDANCE_GRANTS / resolveAffordance reader): 183 files passed, 2258 tests passed and 1 skipped, exit 0. R2: vitest packages/plugin-grid/ and packages/plugin-list/ plus 5 outside inline-edit suites (plugin-view ObjectView.namedViewInlineEdit-10885, tableEditable-10976, namedViewEditInlineFold-5144; app-shell ObjectView.inlineEditSessionOnly-5144, InterfaceListPage.editInlineFold-5144): 341 files passed, 3339 tests passed, exit 0. R3: vitest packages/components/ and packages/types/: 753 files passed and 1 skipped, 14089 tests passed and 24 skipped, exit 0. How the census population was derived: grep over test files for WRITE_SITES, AFFORDANCE_GRANTS and resolveAffordance found affordanceGrantMap-12082 and rowCrudAffordances; tests that walk the tree for write calls (readdirSync or a walk, plus .update( .create( .delete( or a write census) found writeRefusalCensus-11583 and feedTypeProducerCensus-5877, plus two scripts tests (docs-build-trigger, shadcn-local-patches) that walk for other reasons. check:metadata-write-doors is the repo script over write doors, and it exits 0. Carried unchanged: the ablation legs and the type-checks from earlier rounds (no source changed since d061c98).",
"gates": [
"pnpm check:metadata-write-doors: exit 0 at b87fdb4",
"node scripts/check-changeset-presence.mjs: exit 0 at b87fdb4 (10 source files of 5 released packages, 1 changeset; the 5th package is plugin-form, by its test file)",
"node scripts/check-changeset-no-major.mjs: exit 0 at b87fdb4",
"node scripts/check-changeset-fixed.mjs: exit 0 at b87fdb4",
"pnpm check:changeset-claims: exit 0 at b87fdb4",
"pnpm check:pending-changeset-literals: exit 0 at b87fdb4",
"pnpm check:new-line-citations, check:control-bytes, check:test-path-roots and check:vi-mock-specifiers: exit 0 each at b87fdb4",
"R1/R2/R3 vitest: exit 0 each (counts in tests)",
"CI: in_progress on b87fdb4 — not awaited, per the role file"
],
"census_fix": {
"moved_from": [
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultRowSave :: update",
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultBatchSave :: update"
],
"moved_to": [
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > writeRowGuarded :: update — mapped(['listInlineEdit'], OBJECT_GRID)",
"packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > resolveRowConflict :: update — mapped(['listInlineEdit'], OBJECT_GRID)"
],
"new_affordance_rows": "none"
},
"changeset_levels": {
"@object-ui/types": "minor",
"@object-ui/components": "minor (measured last round: no export moves, but an accepted input does)",
"@object-ui/plugin-grid": "patch",
"@object-ui/plugin-list": "patch"
},
"line_budget": "not applicable — the diff touches no skills/** and no governed or line-ratcheted ledger",
"files_changed": [
".changeset/12292-grid-save-if-match.md",
"content/docs/components/complex/data-table.mdx",
"content/docs/plugins/plugin-grid.mdx",
"packages/components/src/tests/data-table-batch-per-row-12292.test.tsx",
"packages/components/src/renderers/complex/data-table.tsx",
"packages/plugin-form/src/affordanceGrantMap-12082.test.tsx",
"packages/plugin-grid/README.md",
"packages/plugin-grid/src/ObjectGrid.tsx",
"packages/plugin-grid/src/tests/inlineEditConcurrency-12292.test.tsx",
"packages/plugin-grid/src/tests/inlineEditExitMode.test.tsx",
"packages/plugin-grid/src/tests/inlineEditPersistence.test.tsx",
"packages/plugin-list/src/ListView.tsx",
"packages/plugin-list/src/tests/ListView.concurrencyTokenProjection-12292.test.tsx",
"packages/types/src/data-display.ts"
],
"deviations": [
"Why the earlier rounds missed this: the census lives in plugin-form, outside the populations I derived (plugin-grid, plugin-list, components, types, plus the inline-edit suites). The tree-walking census tests are now part of the population above.",
"The changeset does not list @object-ui/plugin-form. Its only change is the census test file. check-changeset-presence counts it as a 5th released package's source and still passes on the one changeset; the fixed group versions plugin-form with the rest. Adding a plugin-form line would put a CHANGELOG entry on a package whose shipped code did not change. Left as is; the seat can add a patch line if its reading differs.",
"The PR body is still stale in one line from the previous round (the changeset levels, and it does not mention the census entry move). Per the no-PR-body-edit rule I did not touch it. The edits for the seat to write: the Changeset bullet becomes 'minor for @object-ui/types and @object-ui/components, patch for @object-ui/plugin-grid and @object-ui/plugin-list'; and add under What changed: 'plugin-form affordanceGrantMap-12082 write census: the grid's two update sites move to ObjectGrid > writeRowGuarded and ObjectGrid > resolveRowConflict, still mapped to listInlineEdit.'",
"The worktree was recreated at 0af4fcc, reinstalled and rebuilt, then removed after the runs (node_modules first).",
"Two lock attempts hit queue-timeout (exit 99, never acquired). Both were re-queued under the same slot and then ran; no result in this report comes from an unacquired run."
],
"mcp_calls": "0",
"api_writes": "This round: 1 — this os-dev-report comment, via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos//issues/12292/comments). git push of b87fdb4 is not a REST write. No label write, no PR body edit. Earlier rounds: 3 (pr_create for #12309 with its assignee POST; os-dev-report comments 6139004493 and 6139070698).",
"open_questions": [],
"out_of_scope_findings": [
"class: a · filed by the seat as objectui#12310 (pending edits keyed by row index). Nothing about it is in this PR.",
"carrier: 承接者:无 · noted, not filed — ApiDataSource (@object-ui/core) throws a plain Error on any non-2xx, so a 409 through it is not recognised as CONCURRENT_UPDATE on any surface. The console uses data-objectstack."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
Landed: objectui PR #12309 →
main3092aca·domain:uiseat 1 ·session_01CsyMRsFJBKNEMa4oPY1b5z· 2026-10-13T07:19Zcompletedby the PR'sFixes #12292. No other lane card closed in the window: objectui#12301 closed by its own PR, objectui#12312.main3092acaare blob-identical to the head the contract review judged,b87fdb4(record6140017858, PASS; seat ACCEPT6140199590).pm:dispatchedwas removed and read back.domain:ui,area:records, the type and the priority stay.@object-ui/typesand@object-ui/componentsminor,@object-ui/plugin-gridand@object-ui/plugin-listpatch.main:updated_atasIf-Match, and the next save of a row carries the token the server answered with.409opens the record surfaces'ConcurrentUpdateDialog(Reload, Overwrite, Cancel), with no silent retry.{ failed }, and rolls back nothing the server accepted.ListViewprojectsupdated_atwhile its grid is in inline-edit mode, so the console's rows carry the token.data-table.tsx.Generated by Claude Code