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 }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
persistViewPatch writes {...baseViewDef, ...patch}, freezing the view's effective filter into the overlay — store the patch only #5233
Filed by the triage seat (session session_014tGY3fzu4uwCoe7HrfUtWg) as the destination card for a defect the maintainer ruled on 2026-08-12 but which never got its own card. Unassigned — for the repo:objectui lane.
The defect
persistViewPatch (packages/data-objectstack/src/index.ts) writes {...baseViewDef, ...patch}, so an overlay written by a mere sort/columnState change copies the view's current effective filter (and every other base key) into the overlay verbatim. The filter is pinned as-of-write: a later change to the SOURCE view's filter never reaches users whose overlay carries the frozen copy.
Diagnosed on objectstack#7494 (PM escalation comment 5252396682, "related observation"); re-verify the write site on current main before coding — line numbers and shape may have moved since PR #4713 touched this file. (Claim amendment on this thread: the write site actually lives in packages/app-shell/src/views/ObjectView.tsx on current main — defect reproduces verbatim, only attribution was off.)
The PM analysis on the same thread notes this is a bug under either scoping contract — it does not depend on the org-wide ruling.
Care points
Existing overlay rows already carry frozen base keys; decide (and state in the PR) whether the fix strips them on next write, migrates them, or tolerates them on read — silent tolerance is the shape to avoid. (2026-08-19 status: the read-side half shipped in PR fix(data-objectstack,app-shell): a view overlay contributes only the keys it owns #5272 — existing rows' disposition = documented + tested tolerate-on-read. The remaining work is the write half, blocked on the spec-side key admission above; see the 2026-08-19 maintainer-ruling comment.)
Sibling card (same ruling): the org-wide wording + permission-gate card filed alongside this one. Same file surface — serialize or combine per the lane PM's call.
Related
objectstack#7494 (ruling record) · objectui#4227 / PR #4713 (same namespace, closed) · objectstack-ai/objectstack#9933 (the spec half this card waits on)
Claim: PM loop round 6
Session: session_01RV6yuVCxymHYE16PL9vQkE
Branch: claude/issue-5233-persist-view-patch-only
Worktree: objectui-issue-5233
Domain: repo:objectui (execution seat)
File surface: packages/data-objectstack/src/index.ts (the persistViewPatch write site and its read counterpart), plus tests in that package. ⛔ OUT: packages/app-shell/src/views/metadata-admin/** (#4266 dispatched this round), packages/plugin-dashboard, packages/i18n.
Container & model: S, mode:subagent, model: opus — the write fix is one spread; the existing-rows call is the judgement.
Clause-②: no — it narrows what gets persisted. Nothing about the accept set or the public surface moves.
Serial constraints cleared: packages/data-objectstack is free — nothing open touches it. ⚠️#5232 is the sibling from the same ruling and shares this file surface. I am serializing, not combining: its work is console wording/UX plus a permission-gated write path — same file, different fix pattern, so it fails the family-batch test. It is not dispatched; do not reach for it.
Zone 1 — ruled, and the ruling is unusually specific
persistViewPatch writes {...baseViewDef, ...patch}, so an overlay written by a mere sort or columnState change copies the view's current effective filter — and every other base key — into the overlay verbatim. The filter is then pinned as-of-write: a later change to the source view's filter never reaches users whose overlay carries the frozen copy.
The user-visible shape: an admin edits a view's filter, and the people who once reordered a column silently keep the old filter forever, with nothing anywhere reporting it.
⛔ Re-verify the write site on current main before coding. The card says so explicitly — PR#4713 touched this file, so line numbers and shape may have moved. Take anchors from the tree.
The one real judgement — and it is yours, with a floor
Existing overlay rows already carry frozen base keys. Three dispositions, and the card names the one that is out:
Strip on next write — simple; stale rows persist until touched.
Migrate — clean end state; needs a migration path and a way to run it.
Tolerate on read — cheap and immediately effective for every existing row, if the read applies only the keys a patch legitimately owns.
⛔ Silent tolerance is the shape to avoid — that is the card's wording and it is the floor. Note it forbids silent tolerance, not tolerance: an explicit, documented, tested read-side narrowing is a legitimate choice. What is not legitimate is a fix that leaves existing rows quietly overriding the source view with no test and no note.
State which you chose and why in the PR body, with the consequence for rows written before the fix. If you find a fourth option, argue it.
Tests
The defect pin: write an overlay via a sort/columnState change, then change the source view's filter, and the overlay user sees the new filter. This must fail against pre-fix source — it is the whole card.
The overlay still carries what it legitimately owns (the sort / columnState it was written for).
Whichever disposition you pick for existing rows, pin it: a row written in the old shape must behave the way you decided, and the test must say which decision it encodes.
Reverse-verify: restore the write site, keep the tests, predict the reds before running, report predicted vs observed.
Gates
Derive from your diff. scripts/pm/dispatch-gates.mjs is objectstack-only — here derive from .github/workflows and package.json. Changeset required (@object-ui/data-objectstack); patch or minor, ⛔ never major.
⛔ Do not background a long check and end your turn. Narrow and declare the narrowing if the shared verify lock is contended.
Deliver
Draft PR against main, first body line Fixes #5233. Push the branch early. Return the JSON report and post it as a comment whose first line is the literal <!-- os-dev-report --> marker.
Claim amendment (same claim, session session_01RV6yuVCxymHYE16PL9vQkE, branch claude/issue-5233-persist-view-patch-only) — file surface widened, declared before the PR:
packages/data-objectstack/src/index.ts + tests in that package (as dispatched), plus
packages/app-shell/src/views/ObjectView.tsx and a new test beside it.
Reason: the card and the dispatch both place persistViewPatch in packages/data-objectstack/src/index.ts. On current main it lives in packages/app-shell/src/views/ObjectView.tsx (the useCallback at ~L698; the { ...baseViewDef, ...patch } write at ~L727), and so does its read counterpart (sanitizeViewOverride / loadViewOverrides, the seam where an override is merged over the source view). The adapter is what persists the body verbatim. The defect reproduces exactly as the card describes — only the file attribution was off.
Still OUT and untouched, as dispatched: packages/app-shell/src/views/metadata-admin/**, packages/plugin-dashboard, packages/i18n. Nothing in this claim reaches #5232's surface (console wording / permission-gated write path).
{
"issue": 5233,
"status": "needs_decision",
"branch": "claude/issue-5233-persist-view-patch-only",
"pr": "https://github.com/objectstack-ai/objectui/pull/5272",
"premise_still_valid": true,
"summary": "The defect is real and reproduces as described; one attribution correction: persistViewPatch and its read counterpart live in packages/app-shell/src/views/ObjectView.tsx on current main, not in data-objectstack (file surface amended in a claim comment before the PR). SHIPPED (complete, tested): the read half - a personalization overlay row now contributes only the five keys it owns (rowHeight, sort, hiddenFields, columnState, inlineEdit) via narrowPersonalizationOverlay + VIEW_OVERLAY_OWNED_KEYS, new exports from data-objectstack, applied in sanitizeViewOverride, the one seam both loadViewOverrides branches pass through. Every base key already frozen into every stored row stops shadowing the source view on the next read: no migration, no rewrite at rest. Existing-rows disposition = tolerate on read, chosen deliberately, documented and pinned (the card forbids SILENT tolerance, not tolerance). NOT SHIPPED: the ruled write-at-rest change - measured, the platform refuses it (see open_questions), so I did not guess. PR body is 'Part of #5233', not 'Fixes' (the dispatch said Fixes): a merge must not close the card while the ruled half is undelivered - the standing clause overrides the dispatch here and I am flagging the conflict as instructed.",
"tests": "All at HEAD 670380763 unless noted. (1) pnpm exec vitest run packages/data-objectstack/ -> 'Test Files 41 passed (41) / Tests 556 passed (556)'. (2) pnpm exec vitest run packages/app-shell/ -> 'Test Files 447 passed (447) / Tests 4313 passed | 1 skipped (4314)' at f0b6bf59a; the only later change is 5 lines inside one data-objectstack test file, so no app-shell input moved. (3) The two new files verbose -> 16 passed. (4) type-check + lint for both packages via --filter: both script names echoed ('packages/app-shell type-check$ tsc --noEmit && tsc -p tsconfig.test.json ... Done'), so neither was a zero-match silent pass. (5) check:control-bytes, check:phantom-deps, check:self-import, check:spec-symbols, check:esm-specifiers, check-changeset-no-major.mjs, check-changeset-fixed.mjs -> all pass. REVERSE VERIFICATION, two ablation legs, predictions recorded first: leg A (wiring removed from sanitizeViewOverride) predicted 4 red in the app-shell pin / 8 green in the adapter pin -> observed exactly that, reds showing the frozen filter value 'open' where 'qualified' was expected; leg B (narrowPersonalizationOverlay made inert) predicted 4 red / 4 green in the adapter pin -> observed 3 red / 5 green. The miss was real and useful: 'omits an owned key the row does not carry' used a fixture whose key set was already the narrowed one, so it could not fail; fixture strengthened (commit 670380763) and leg B re-run gives the predicted 4 red / 4 green. NO REBUILD NEEDED FOR EITHER LEG and none could hide a stale dist: the root vitest.config.mts aliases @object-ui/data-objectstack to packages/data-objectstack/src, so tests resolve source - and the ablations turning red is itself the proof of that resolution. CI: in_progress at report time (draft PR just opened).",
"open_questions": [
{
"question": "The ruling's own shape ('store the patch only') is REFUSED by the platform for a columnState-only patch, which is the most common toolbar write (a column resize/reorder travels alone). Measured with @objectstack/spec 17.0.0 as installed, and columnState appears nowhere in the framework's packages/spec/src either: ViewMetadataSchema.safeParse({columnState, object, name, viewKind, _isOverride}) -> REFUSE, 'Not a `view` body: no recognized `view` key ... Received unrecognized keys `columnState`, `_isOverride`'. sort/rowHeight/hiddenFields/inlineEdit each parse fine alone; columnState does not, because it is objectui's own NON-AUTHOR runtime key (plugin-grid gridNonAuthorKeys ruling). saveMetaItem validates every view write after normalizeViewMetadata inherits identity, so a patch-only write for a column drag would return 422 INVALID_METADATA and the resize would not persist. Today it survives only because the copied base supplies a recognized key. Which way does the storage half go? (No framework issue filed - a search of objectstack found no prior art, and filing one would pre-empt this ruling.)",
"options": [
"A. Teach the platform's view schema the runtime overlay keys (framework change in packages/spec), then narrow the write to the patch as ruled.",
"B. Narrow the write anyway and accept losing columnState persistence until A lands.",
"C. Keep the fat write (what this PR does) and rely on the shipped read-side narrowing: no frozen key reaches a user, but the stale bytes stay at rest and readers outside objectui (Studio, the server-side switcher) still see a snapshot.",
"D. Move personalization out of the `view` metadata namespace entirely - the parked per-user direction on objectstack#7494 / #5232."
],
"recommendation": "A, with C shipped now as the harm-elimination half. Real business need: column resize/reorder is a live, used feature and B removes it; C alone leaves real stale rows readable by Studio and the server switcher. Long-term soundness: the refusal comes from the producer's own schema describing a row the platform already stores, so contract-first says fix it there rather than keeping a fat write that launders an unrecognized key past validation - that is exactly the second de-facto contract AGENTS #0.1 warns about. Making AI-written metadata hard to get wrong: A makes the overlay's key set declared and enforced at the publish door, so an agent (or a future call site) cannot invent an overlay key that silently rides along inside a copied view body; D is the cleanest end state but is parked and far larger than this card. Note A must decide whether columnState becomes authorable surface or is admitted as an explicitly runtime-only key - objectui's own gridNonAuthorKeys ruling says it is not author surface, so the honest form of A is the latter."
}
],
"out_of_scope_findings": []
}
Unlock sweep: blocker cleared, and the spec pin is measured to cover it.pm:blocked → pm:queue (paired write, same stroke).
Two-part release condition, both halves measured:
half
reading
blocker lands
objectstack-ai/objectstack#9933 — CLOSED 2026-08-19T13:24:03Z, via merged PR objectstack#9996
shipped in a released spec
packages/spec/CHANGELOG.md on objectstack origin/main lists #9933 under ## 17.1.0
objectui's pin carries it
pnpm-lock.yaml on objectui origin/main resolves @objectstack/spec@17.1.0
So ViewMetadataSchema should now accept a columnState-only patch, which is exactly the 422 that stopped the write-side half of the ruling here.
Authorization: maintainer direct-dispatch channel, this session, verbatim: 「可以解锁却没解 的帮我处理」. PM session session_0124Qg8rLvpXnQDwCmpKUmaJ. Block age at unlock: 4 days.
⚠️The premise was NOT re-verified — that is the claiming seat's first step. The upstream card was explicit that admitting columnState as a runtime-only overlay key and making it author-writable are two different spec changes, and only the first shipped. Confirm which one the pin actually gives you before narrowing the write.
⚠️ This card carries no domain:* — it re-enters the queue lane-less and is invisible to lane-scoped candidate queries until triage stamps it. ⛔ Not stamped here.
Triage: domain:ui. The fix lands in packages/data-objectstack (persistViewPatch) with the call side in packages/app-shell — the patch-only write the ruling requires. Published library source.
The spec-side half of this split already shipped (objectstack#9933, released in @objectstack/spec 17.1.0), which is why this card was returned to pm:queue earlier in this session. What remains is objectui's own write-side narrowing — the ui stream.
Routed via the maintainer direct-dispatch channel, this session, verbatim: 「objectui 有 79 张卡在车道视图之外(占该仓 35%),你帮我分批处理」. PM session session_0124Qg8rLvpXnQDwCmpKUmaJ. Not a Routine triage fire — the triage seat remains the standing producer of domain:* and may re-grade.
Claim: domain:ui execution seat (seat post #5560), round 22, slot 3 — replacement for #4676, which closed completed with zero code change (its Option C landed upstream; the probe confirmed the pin covers it).
Session: session_01EuPCi56cnGyykygi3z9w4m
Branch: claude/issue-5233-persist-patch-only
Worktree: dedicated, off origin/main at 9850c6e4e
Domain: domain:ui · Tier: claude-opus-5
Clause-②: no on this side. The contract half already shipped upstream (objectstack#9933, in @objectstack/spec 17.1.0, dispatched at tier there). What remains is objectui narrowing what it writes — no accept-set moves here.
Prior claim on this thread is historic, not live:session_01RV6yuVCxymHYE16PL9vQkE (2026-08-18) ran its dispatch to completion — it fired the rollback clause, escalated, landed PR #5272 as Part of, and returned the card unassigned. Re-read confirms no live claim.
⛔ THE TRIAGE LANDING LINE IS INVERTED — measured on origin/main9850c6e4e before dispatch. Triage says "the fix lands in packages/data-objectstack (persistViewPatch) with the call side in packages/app-shell". It is the other way round, and the card's own amendment already said so:
persistViewPatch is defined at packages/app-shell/src/views/ObjectView.tsx:738.
The fat write is at ObjectView.tsx:766-770 — dataSource.updateViewConfig(objectName, viewIdLocal, { ...baseViewDef, ...merged }, { isSavedView: targetIsSavedView }).
packages/data-objectstack/src/index.ts:1337-1345 holds a docblock that describes the defect ("this adapter persists what it is given"), not the defect itself.
Verify all of it yourself; two cards in this round shipped with a stale file attribution.
File surface granted (write):
packages/app-shell/src/views/ObjectView.tsx
packages/app-shell/src/views/ObjectView.*.test.*
packages/data-objectstack/src/index.ts (the docblock at :1337-1345 only — see below)
packages/data-objectstack/src/*.test.ts
.changeset/*
⚠️ The data-objectstack docblock currently states the fat write as present-tense fact. If your change makes it false, updating it is in scope; changing that adapter's behaviour is not — it persists what it is given, and that is correct.
Serial constraints: ⛔ apps/console/.env.* · examples/console-starter/** held by #5745 (slot 1); ⛔ packages/auth/** held by #5731 (slot 2, PR #5764 open).
⚠️packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts already exists and pins the current fat-write shape — including a regex over every persistViewPatch(…) call. It is the guard your change must move deliberately, not incidentally. Read it before writing; if it goes red, that is the point, and the update must be argued in the report, not performed silently.
{
"issue": 5233,
"status": "done",
"branch": "claude/issue-5233-persist-patch-only",
"pr": "https://github.com/objectstack-ai/objectui/pull/5772",
"premise_still_valid": true,
"summary": "PREMISE RE-VERIFIED FIRST (the unlock sweep's instruction, and this card's own history of the rollback clause firing): probed the installed @objectstack/spec 17.1.0 and it now ACCEPTS a columnState-only patch carrying identity (object + viewKind + name), which is the 422 that stopped the write half in August. Controlled both ways in the same run - columnStates, columnstate and zzzNotAKey are still REFUSED with 'Not a `view` body: no recognized `view` key', wrong inner types are refused (so the key is genuinely validated, not stripped), identity-less bodies are refused, and an AUTHORED ViewItem carrying columnState is still refused. So the pin gives the runtime-only admission the ruling asked for, not authorability. Premise holds; proceeded. LANDING SITE: the triage line is inverted, as the claim comment said - persistViewPatch is DEFINED at packages/app-shell/src/views/ObjectView.tsx:738 and the fat write was at :766-771; packages/data-objectstack/src/index.ts:1337-1345 only DESCRIBES the defect. Verified on 9850c6e4e myself. CHANGE: the write now routes through a new exported seam buildPersistedViewBody(baseViewDef, patch, opts). For a SYSTEM view's overlay the body is the patch alone plus viewKind when the active tab carries one - identity, not content, the same line VIEW_OVERLAY_IDENTITY_KEYS draws on the read side, so the write now produces exactly the shape the reader keeps; object, name and the overlay marker are stamped by updateViewConfig itself, and label is deliberately not kept (a stored one is a snapshot of the source view's label). filter, columns, label, type, isDefault - the ruling's own examples of the frozen copy - are all gone. THE ONE JUDGEMENT, and it is measured not argued: updateViewConfig persists through client.meta.saveItem, which is a PUT of the WHOLE DOCUMENT (the adapter's own updateView does read-modify-write precisely because saveItem does not - its 404 path says 'there is nothing to merge onto. Fail loudly instead of emitting the partial write'). So for a SAVED view, whose row IS the view and which has no source underneath it for a copied key to shadow, a patch-only PUT would not narrow the row - it would DELETE the user's view definition. That branch is left byte-identical to the pre-fix write, and ablation C proves the guard bites, failing with 'expected undefined to deeply equal { type: kanban, ...(1) }'. EXISTING ROWS: tolerate-on-read (PR #5272) still holds, and because this write replaces the document the next toggle also STRIPS a pre-fix fat row at rest - so the issue's 'strip on next write' arrives as a consequence rather than a second mechanism. No migration. Both pinned. THE EXISTING GUARD MOVED DELIBERATELY, ARGUED NOT SILENT: ObjectView.overlayPatchOnly.test.ts pinned the fat write as current fact. Its first case is retitled 'a PRE-FIX fat overlay no longer shadows the source view on read' and its fixture comment now says USED TO send - assertions UNCHANGED and still green, still produced by the real adapter write rather than a fixture. The PERSIST_CALLS regex ratchet did NOT move: the fix is inside the callee, so all seven call sites are byte-identical. A SECOND ratchet was added because ablation B showed the gap - reverting only the wiring leaves every behavioural test green. Sibling assumptions checked as asked: ObjectView.overlayFilterRecovery.test.tsx:150 and ObjectView.defaultViewIdentity.test.tsx:238 - neither becomes false; defaultViewIdentity is about the persistence KEY, unchanged, left alone; overlayFilterRecovery's comment claimed the copy in present tense and is tense-corrected only.",
"tests": "All at final commit 9a5fce746 (git rev-parse --short HEAD on the tree the union below was run against). SUITES: `pnpm exec vitest run packages/app-shell/ packages/data-objectstack/ --maxWorkers=2` -> 'Test Files 543 passed (543)' / 'Tests 5475 passed | 1 skipped (5476)', 0 failures. After the comment-tense pass, `vitest run packages/data-objectstack/ packages/app-shell/src/views/` -> 'Test Files 342 passed (342)' / 'Tests 3496 passed | 1 skipped (3497)', 0 failures. The guard file alone -> 'Tests 17 passed (17)'. TYPE-CHECK: `pnpm --workspace-concurrency=2 --filter @object-ui/app-shell --filter @object-ui/data-objectstack type-check` -> exit 0 with BOTH script names echoed ('packages/data-objectstack type-check$ tsc --noEmit ... Done', 'packages/app-shell type-check$ tsc --noEmit && tsc -p tsconfig.test.json ... Done'), so neither was a zero-match silent pass. Dependency closure built first: `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` -> exit 0. GATES (each judged by the gate's own printed verdict line, not by a bare $?; every command redirected to a file BEFORE any pipe): check:control-bytes -> 'check-control-bytes: OK (scanned 4803 tracked text file(s); skipped 85 binary)'; check:phantom-deps -> 'Every in-scope import is declared by the package that publishes it.'; check:self-import -> 'No package names itself inside its own src/.'; check:esm-specifiers -> 'Specifier leg: no un-ledgered package emits an extensionless relative specifier.'; check:spec-symbols -> 'spec symbol derivation: 1298 files scanned against 4966 spec export names'; check-changeset-no-major.mjs -> 'No changeset declares a major bump.'; check-changeset-presence.mjs -> '9 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'; check-lint-coverage.mjs -> 'lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total)' (note: that script measures COVERAGE, not verdicts - it is not a substitute for running lint). LINT NARROWED, AND THE NARROWING IS DECLARED WITH ITS THREE PIECES: pnpm lint is `turbo run lint`, i.e. each package's own `eslint .`; ran it for the two packages that contain changed files -> app-shell 935 files / 0 errors / 2560 warnings, data-objectstack 48 files / 0 errors / 368 warnings, plus lint:root -> 0 errors. (1) population is eslint's own selection under the root eslint.config.js, not a list I chose; (2) counts read off --format json; (3) the narrowing excludes nothing because type-aware linting is NOT enabled - the config extends tseslint.configs.recommended, not recommendedTypeChecked, and languageOptions declares no project/projectService, so this diff cannot move the verdict on any file outside those two packages. Warnings are pre-existing no-explicit-any in an any-typed file; eslint exits 0 (no --max-warnings anywhere) and +2 are the new seam's own loosely-typed record signature. CONTROL BYTES over the diff, CONTROLLED: grep -naP over the 10 changed files -> 0 hits, and the SAME instrument over the SAME file list finds 47 persistViewPatch hits, so the zero is a reading. ABLATIONS - three legs, predictions recorded BEFORE running, each committed first, mutated, confirmed on disk by ANCHORED COUNTS IN BOTH DIRECTIONS (the deleted anchor going 1 -> 0 and an injected ABLATION_x marker going 0 -> 1, never a bare git diff --stat and never an editor exit code), run, then restored under `trap ... EXIT INT TERM` with the restore re-confirmed in both directions (marker back to 0, anchor back to 1) and a clean `git status`. NO REBUILD WAS NEEDED OR POSSIBLE TO GET WRONG: the root vitest.config.mts aliases @object-ui/data-objectstack to packages/data-objectstack/src and ObjectView.tsx is imported from source, so no dist can go stale here - and the ablations turning red is itself the proof of that source resolution. LEG A (the seam's overlay branch reverted to the fat spread): predicted 6 red / 11 green, with the saved-view guards and the wiring ratchet SURVIVING - observed exactly that, the six write assertions red. LEG B (seam kept, the WIRING reverted at the call site): predicted 1 red - the new wiring ratchet ONLY, all 16 others green - observed exactly that; this is the gap a behavioural-only suite cannot see and the whole reason the second ratchet exists. LEG C (narrow BOTH branches - the over-strict reading of the ruling): predicted 2 red, both saved-view guards - observed exactly that, failing with 'expected undefined to deeply equal { type: kanban, ...(1) }', i.e. the user's deleted view config quoted by the assertion. Leg C is the control that correctly SURVIVES legs A and B and must die here: it is what says the narrowing stopped where it should. DEMONSTRATION (the pair the dispatch asked for, both legs also pinned as tests): the same scenario run twice through the REAL adapter write, REAL batch read and REAL consumer merge. Pre-fix row keys at rest: _isOverride, columnState, columns, filter, isDefault, label, name, object, type - row.filter frozen at status=open. Post-fix row keys at rest: _isOverride, columnState, name, object, viewKind - row.filter undefined. Read honestly: through objectui's OWN switcher both already behave correctly (PR #5272 doing its job), so the user-visible delta is shown against a reader that does NOT run narrowPersonalizationOverlay - Studio, the server-side switcher - where the pre-fix row still serves filter status=open, label 'All Leads', columns [name,status] against an admin who has moved them to status=qualified / 'All Leads (active)' / [name,status,owner], and the post-fix row serves all three new values. That leg is pinned as 'a reader OUTSIDE objectui - no read-side narrowing - now sees the admin edit too', which runs the pre-fix row FIRST as its own control so the post-fix assertions are a measurement and not a tautology. No pixel render was produced: the user-visible difference here is the effective filter a list queries with, there is no live metadata backend in this container, and dressing a transcript up as a screenshot would be worse than saying so. CI: in_progress at report time (draft PR just opened) - not waited on, per the standing clause.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #5773 (label `finding`, unassigned, NOT queued): InterfaceListPage hydrates a hollow view out of a personalization overlay row - a latent dependency on the fat write. For a SYSTEM view served hollow by ADR-0017 expansion whose user has personalized it, hydration used to succeed with the overlay's FROZEN copy of columns and now falls through to defaults. Saved views and already-stored rows are unaffected, and objectui's own switcher is unaffected. The full suites are green, which is evidence the case is UNTESTED rather than unreachable - confirming it wants a dogfood run. InterfaceListPage.tsx is outside this card's fence, so it was recorded, not touched."
]
}
Gates by name at 06:33Z (PR started 06:21:55Z): 19 success + 3 skipped = 22. Zero in_progress, zero failure. (Build Docs 10 s — the path-filtered skip.)
Fence: clean, including the one line I most expected to be crossed. 10 files, all inside the grant. packages/data-objectstack/src/index.ts is +26 −7, which is more than a docblock tense fix looks like — so I read every changed line. All of them begin with *. It is entirely JSDoc; no code moved. The adapter still persists what it is given, and the new prose says so in as many words: "this adapter's behaviour is unchanged by either — it still persists what it is given."
The premise check came first, and it is the reason this card could proceed at all
This card's history is a rollback clause firing: in August the ruled write-side narrowing was stopped by a 422, and the unlock sweep explicitly warned that runtime-only admission and author-writability are two different spec changes and only the first shipped.
Probed against the installed 17.1.0, controlled in both directions in one run:
leg
verdict
columnState-only patch carrying identity
ACCEPTED
columnStates / columnstate / zzzNotAKey
REFUSED — "Not a view body: no recognized view key"
wrong inner types
REFUSED — so the key is genuinely validated, not passed through
identity-less body
REFUSED
an authored ViewItem carrying columnState
REFUSED
That last leg is the one that matters: it proves the pin gives runtime-only admission, not authorability — precisely the distinction the ruling drew and the sweep flagged. The premise holds, and it holds for the right reason.
The judgement of the round: a patch-only PUT would have deleted the user's saved view
updateViewConfig persists through client.meta.saveItem, which is a PUT of the whole document — the adapter's own updateView does read-modify-write precisely because saveItem does not, and its 404 path says "there is nothing to merge onto. Fail loudly instead of emitting the partial write."
So for a saved view, whose row IS the view and which has no source underneath it for a copied key to shadow, narrowing the write would not have narrowed a row — it would have destroyed the user's view definition. That branch is left byte-identical to the pre-fix write, and ablation C proves the guard bites, failing with expected undefined to deeply equal { type: kanban, …(1) } — the deleted view config quoted by the assertion.
The ruling says "store the patch only". A literal reading would have shipped data loss. This is the distinction between honouring a ruling's observable requirement and honouring its wording, and it was found by reading what saveItem actually does rather than by reasoning about what "patch" means.
The existing guard moved deliberately, and the gap it left was found by ablation
ObjectView.overlayPatchOnly.test.ts pinned the fat write as current fact — I flagged that a guard rewritten to match new behaviour is indistinguishable from a guard deleted. What happened instead:
The first case is retitled and its fixture comment says "USED TO send" — assertions unchanged and still green, still produced by the real adapter write rather than a fixture.
The PERSIST_CALLS regex ratchet did not move at all, because the fix is inside the callee and all seven call sites are byte-identical.
⚠️And a second ratchet was added because ablation B exposed the gap: reverting only the wiring left every behavioural test green. That is a hole a behavioural-only suite structurally cannot see, and it was found by mutation rather than by inspection.
Three ablations, predictions recorded before running, all three observed exactly: A (seam reverted to the fat spread) 6 red / 11 green; B (wiring reverted) exactly 1 red — the new ratchet alone; C (both branches narrowed — the over-strict reading) exactly 2 red, both saved-view guards. Leg C is the control that correctly survives A and B and must die here: it is what says the narrowing stopped where it should.
The demonstration is read honestly, which is why it is worth something
The pair I asked for is there — but through objectui's own switcher both pre- and post-fix rows already behave correctly, because PR #5272's read-side narrowing is doing its job. Rather than dress that up, the user-visible delta is shown against a reader that does not run narrowPersonalizationOverlay — Studio, the server-side switcher — where the pre-fix row still serves filter: status=open, label: 'All Leads', columns: [name,status] against an admin who has moved them, and the post-fix row serves all three new values. The pre-fix row runs first, as its own control, so the post-fix assertions are a measurement rather than a tautology.
And no pixel render was produced, with the reason stated: the user-visible difference is the effective filter a list queries with, there is no live metadata backend in this container, and dressing a transcript up as a screenshot would be worse than saying so. Correct.
Row keys at rest, before and after: _isOverride, columnState, columns, filter, isDefault, label, name, object, type → _isOverride, columnState, name, object, viewKind. filter, columns, label, type, isDefault — the ruling's own examples of the frozen copy — are gone.
No migration, and none needed: because the write replaces the document, the next toggle also strips a pre-fix fat row at rest. The card's "strip on next write" arrives as a consequence of the fix rather than as a second mechanism. Both halves pinned.
Sibling assumptions checked as asked: overlayFilterRecovery.test.tsx:150 is tense-corrected only; defaultViewIdentity.test.tsx:238 is about the persistence key, unchanged, and correctly left alone.
Filed out of scope: #5773 — InterfaceListPage hydrates a hollow view out of an overlay row, a latent dependency on the fat write. The observation that makes it worth reading: "the full suites are green, which is evidence the case is UNTESTED rather than unreachable." Outside this card's fence, so recorded rather than touched.
Flipping ready → auto-merge (SQUASH). Landing verification against origin/main follows.
Activity
os-support-ai commentedon Aug 18, 2026
Claim: PM loop round 6⚠️ #5232 is the sibling from the same ruling and shares this file surface. I am serializing, not combining: its work is console wording/UX plus a permission-gated write path — same file, different fix pattern, so it fails the family-batch test. It is not dispatched; do not reach for it.
Session:
session_01RV6yuVCxymHYE16PL9vQkEBranch:
claude/issue-5233-persist-view-patch-onlyWorktree:
objectui-issue-5233Domain:
repo:objectui(execution seat)File surface:
packages/data-objectstack/src/index.ts(thepersistViewPatchwrite site and its read counterpart), plus tests in that package. ⛔ OUT:packages/app-shell/src/views/metadata-admin/**(#4266 dispatched this round),packages/plugin-dashboard,packages/i18n.Container & model:
S,mode:subagent,model: opus— the write fix is one spread; the existing-rows call is the judgement.Clause-②: no — it narrows what gets persisted. Nothing about the accept set or the public surface moves.
Serial constraints cleared:
packages/data-objectstackis free — nothing open touches it.Zone 1 — ruled, and the ruling is unusually specific
Maintainer
huangyiirene, 2026-08-12, objectstack#7494 comment5261754173, verbatim:>
persistViewPatch只存 patch,不存 merged base —— 独立小修,现在做:它把写入时的有效 filter 冻进 overlay,导致源视图后续的 filter 变更到不了带 overlay 的用户,这与 per-user 之争无关,是纯粹的存储形状错误。Two things the ruling settles for you:
The defect
persistViewPatchwrites{...baseViewDef, ...patch}, so an overlay written by a mere sort or columnState change copies the view's current effective filter — and every other base key — into the overlay verbatim. The filter is then pinned as-of-write: a later change to the source view's filter never reaches users whose overlay carries the frozen copy.The user-visible shape: an admin edits a view's filter, and the people who once reordered a column silently keep the old filter forever, with nothing anywhere reporting it.
⛔ Re-verify the write site on current
mainbefore coding. The card says so explicitly — PR#4713 touched this file, so line numbers and shape may have moved. Take anchors from the tree.The one real judgement — and it is yours, with a floor
Existing overlay rows already carry frozen base keys. Three dispositions, and the card names the one that is out:
⛔ Silent tolerance is the shape to avoid — that is the card's wording and it is the floor. Note it forbids silent tolerance, not tolerance: an explicit, documented, tested read-side narrowing is a legitimate choice. What is not legitimate is a fix that leaves existing rows quietly overriding the source view with no test and no note.
State which you chose and why in the PR body, with the consequence for rows written before the fix. If you find a fourth option, argue it.
Tests
Gates
Derive from your diff.
scripts/pm/dispatch-gates.mjsis objectstack-only — here derive from.github/workflowsandpackage.json. Changeset required (@object-ui/data-objectstack);patchorminor, ⛔ nevermajor.⛔ Do not background a long check and end your turn. Narrow and declare the narrowing if the shared verify lock is contended.
Deliver
Draft PR against
main, first body lineFixes #5233. Push the branch early. Return the JSON report and post it as a comment whose first line is the literal<!-- os-dev-report -->marker.Generated by Claude Code
fix(data-objectstack,app-shell): a view overlay contributes only the …
test(data-objectstack): the no-undefined-keys pin needs a base key to…
os-support-ai commentedon Aug 18, 2026
Claim amendment (same claim, session
session_01RV6yuVCxymHYE16PL9vQkE, branchclaude/issue-5233-persist-view-patch-only) — file surface widened, declared before the PR:packages/data-objectstack/src/index.ts+ tests in that package (as dispatched), pluspackages/app-shell/src/views/ObjectView.tsxand a new test beside it.Reason: the card and the dispatch both place
persistViewPatchinpackages/data-objectstack/src/index.ts. On currentmainit lives inpackages/app-shell/src/views/ObjectView.tsx(theuseCallbackat ~L698; the{ ...baseViewDef, ...patch }write at ~L727), and so does its read counterpart (sanitizeViewOverride/loadViewOverrides, the seam where an override is merged over the source view). The adapter is what persists the body verbatim. The defect reproduces exactly as the card describes — only the file attribution was off.Still OUT and untouched, as dispatched:
packages/app-shell/src/views/metadata-admin/**,packages/plugin-dashboard,packages/i18n. Nothing in this claim reaches #5232's surface (console wording / permission-gated write path).Generated by Claude Code
Generated by Claude Code
os-support-ai commentedon Aug 18, 2026
{ "issue": 5233, "status": "needs_decision", "branch": "claude/issue-5233-persist-view-patch-only", "pr": "https://github.com/objectstack-ai/objectui/pull/5272", "premise_still_valid": true, "summary": "The defect is real and reproduces as described; one attribution correction: persistViewPatch and its read counterpart live in packages/app-shell/src/views/ObjectView.tsx on current main, not in data-objectstack (file surface amended in a claim comment before the PR). SHIPPED (complete, tested): the read half - a personalization overlay row now contributes only the five keys it owns (rowHeight, sort, hiddenFields, columnState, inlineEdit) via narrowPersonalizationOverlay + VIEW_OVERLAY_OWNED_KEYS, new exports from data-objectstack, applied in sanitizeViewOverride, the one seam both loadViewOverrides branches pass through. Every base key already frozen into every stored row stops shadowing the source view on the next read: no migration, no rewrite at rest. Existing-rows disposition = tolerate on read, chosen deliberately, documented and pinned (the card forbids SILENT tolerance, not tolerance). NOT SHIPPED: the ruled write-at-rest change - measured, the platform refuses it (see open_questions), so I did not guess. PR body is 'Part of #5233', not 'Fixes' (the dispatch said Fixes): a merge must not close the card while the ruled half is undelivered - the standing clause overrides the dispatch here and I am flagging the conflict as instructed.", "tests": "All at HEAD 670380763 unless noted. (1) pnpm exec vitest run packages/data-objectstack/ -> 'Test Files 41 passed (41) / Tests 556 passed (556)'. (2) pnpm exec vitest run packages/app-shell/ -> 'Test Files 447 passed (447) / Tests 4313 passed | 1 skipped (4314)' at f0b6bf59a; the only later change is 5 lines inside one data-objectstack test file, so no app-shell input moved. (3) The two new files verbose -> 16 passed. (4) type-check + lint for both packages via --filter: both script names echoed ('packages/app-shell type-check$ tsc --noEmit && tsc -p tsconfig.test.json ... Done'), so neither was a zero-match silent pass. (5) check:control-bytes, check:phantom-deps, check:self-import, check:spec-symbols, check:esm-specifiers, check-changeset-no-major.mjs, check-changeset-fixed.mjs -> all pass. REVERSE VERIFICATION, two ablation legs, predictions recorded first: leg A (wiring removed from sanitizeViewOverride) predicted 4 red in the app-shell pin / 8 green in the adapter pin -> observed exactly that, reds showing the frozen filter value 'open' where 'qualified' was expected; leg B (narrowPersonalizationOverlay made inert) predicted 4 red / 4 green in the adapter pin -> observed 3 red / 5 green. The miss was real and useful: 'omits an owned key the row does not carry' used a fixture whose key set was already the narrowed one, so it could not fail; fixture strengthened (commit 670380763) and leg B re-run gives the predicted 4 red / 4 green. NO REBUILD NEEDED FOR EITHER LEG and none could hide a stale dist: the root vitest.config.mts aliases @object-ui/data-objectstack to packages/data-objectstack/src, so tests resolve source - and the ablations turning red is itself the proof of that resolution. CI: in_progress at report time (draft PR just opened).", "open_questions": [ { "question": "The ruling's own shape ('store the patch only') is REFUSED by the platform for a columnState-only patch, which is the most common toolbar write (a column resize/reorder travels alone). Measured with @objectstack/spec 17.0.0 as installed, and columnState appears nowhere in the framework's packages/spec/src either: ViewMetadataSchema.safeParse({columnState, object, name, viewKind, _isOverride}) -> REFUSE, 'Not a `view` body: no recognized `view` key ... Received unrecognized keys `columnState`, `_isOverride`'. sort/rowHeight/hiddenFields/inlineEdit each parse fine alone; columnState does not, because it is objectui's own NON-AUTHOR runtime key (plugin-grid gridNonAuthorKeys ruling). saveMetaItem validates every view write after normalizeViewMetadata inherits identity, so a patch-only write for a column drag would return 422 INVALID_METADATA and the resize would not persist. Today it survives only because the copied base supplies a recognized key. Which way does the storage half go? (No framework issue filed - a search of objectstack found no prior art, and filing one would pre-empt this ruling.)", "options": [ "A. Teach the platform's view schema the runtime overlay keys (framework change in packages/spec), then narrow the write to the patch as ruled.", "B. Narrow the write anyway and accept losing columnState persistence until A lands.", "C. Keep the fat write (what this PR does) and rely on the shipped read-side narrowing: no frozen key reaches a user, but the stale bytes stay at rest and readers outside objectui (Studio, the server-side switcher) still see a snapshot.", "D. Move personalization out of the `view` metadata namespace entirely - the parked per-user direction on objectstack#7494 / #5232." ], "recommendation": "A, with C shipped now as the harm-elimination half. Real business need: column resize/reorder is a live, used feature and B removes it; C alone leaves real stale rows readable by Studio and the server switcher. Long-term soundness: the refusal comes from the producer's own schema describing a row the platform already stores, so contract-first says fix it there rather than keeping a fat write that launders an unrecognized key past validation - that is exactly the second de-facto contract AGENTS #0.1 warns about. Making AI-written metadata hard to get wrong: A makes the overlay's key set declared and enforced at the publish door, so an agent (or a future call site) cannot invent an overlay key that silently rides along inside a copied view body; D is the cleanest end state but is parked and far larger than this card. Note A must decide whether columnState becomes authorable surface or is admitted as an explicitly runtime-only key - objectui's own gridNonAuthorKeys ruling says it is not author surface, so the honest form of A is the latter." } ], "out_of_scope_findings": [] }Generated by Claude Code
os-support-ai commentedon Aug 18, 2026
回翻条款触发 —— 裁定的前提被实测推翻,本席不代裁,升级给维护者
PM seat (
session_01RV6yuVCxymHYE16PL9vQkE). PR #5272 是Part of #5233,不是Fixes—— 这是对的,本卡保持 open。被推翻的是哪一句
维护者 2026-08-12 裁定(objectstack#7494,评论
5261754173)原话:「纯粹的存储形状错误」这半句不成立。 照字面执行「只存 patch」,平台契约会拒绝这个写入:
原因是
columnState是 objectui 自己的 non-author 运行时键,@objectstack/spec里根本没有它。后果不是「形状更干净」,而是列宽调整/列重排这个最高频的工具栏动作会 422INVALID_METADATA,用户的调整根本存不下来 —— 它今天能存活,恰恰是因为那个被判为 bug 的 fat write 顺带塞进了一个被识别的键。本席独立复核了这条读数(它是整个分叉的承重点)
不采信 dev 的单方读数,自己对
objectstack-ai/objectstack的origin/main跑了一遍五个键:packages/spec/src命中文件数sortinlineEditrowHeighthiddenFieldscolumnState其余四个键的非零命中就是这次零命中的 counter-probe —— grep 够得到这棵树,所以
columnState的零是读数不是死查询。dev 的测量成立。已落地的半边(PR #5272)与被挡住的半边
落地:overlay 读的时候只贡献它自己拥有的五个键(
sanitizeViewOverride这一个接缝,两个分支都过它)。效果是存量行下一次页面加载就不再遮蔽源视图 —— 无迁移、无重写、无用户动作,危害当场消除。挡住:写入端仍是 fat write。字节还是陈旧的,任何 objectui 之外的读者(Studio、服务端 switcher)仍看到快照。
四个选项(维护者的决定 —— 契约形状与发版从不由 AI 定)
columnState持久化先丢掉,等 (1)。最省事,但删掉一个正在工作的功能。view元数据命名空间 —— objectstack#7494 / Implement the objectstack#7494 ruling: view config is explicitly org-wide — console wording/UX + permission-gated write path #5232 上那个已停放的 per-user 方向。dev 推荐 (1) + 现在先落 (3)。本席同意这个读法:(2) 拿一个静默的陈旧换一个可见的功能回归,是更差的交易;(4) 是停放中的大改。但 (1) 动的是
packages/spec契约面 —— 那既在人工地板上(协议/公开契约变化),也归domain:spec座位,⛔ 不是本席能裁的,也不是本仓能改的。⛔ 本席不代裁、不静默重裁、不自行收窄写入。 卡在 PR #5272 落地后转
needs-user-decision。若裁 (1),下一步是在 objectstack 立 spec 侧卡,本卡挂Blocked-by:排在它后面。顺带更正本席自己的认领评论
我在认领里把文件面写成
app-shell/src/views/ObjectView.tsx+data-objectstack—— 方向对了,但卡面把persistViewPatch归给packages/data-objectstack/src/index.ts是错的,该函数与{ ...baseViewDef, ...patch }展开都在packages/app-shell/src/views/ObjectView.tsx(useCallback约 :698,写入约 :727);adapter 的updateViewConfig只是原样持久化。缺陷本身逐字复现,错的只是归属。Generated by Claude Code
os-support-ai commentedon Aug 18, 2026
两条补充,都影响上面那个决定 —— 以及一条本席的自纠。
选项 ① 的诚实形态:runtime-only,不是可作者化
dev 报告里的一点,我认为它把选项 ① 的含义收窄了,值得单独说清楚:如果裁 ①,
columnState应当被 spec 认成显式的 runtime-only 键,而不是变成新的可作者化面。依据是 objectui 自己已有的裁定 ——plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx判定它不是作者面。这两种改法在
packages/spec里是两件不同的事,代价也不同(一个是承认既存运行时形状,一个是扩大公开接受集),⛔ 别当成同一个选项批。支持 ① 的另一条论据:fat write 正在「蒙混过校验」
维持现状(选项 ③)不只是「字节陈旧」。它的机制是:一个平台不认识的键(
columnState),藏在一个被复制的、平台认识的 view body 里,一起通过了saveMetaItem的校验。也就是说校验没有拒绝它,只是没看见它 —— 这正是 AGENTS #0.1 说的「第二套事实上的契约」:声明面说不认,存储面天天在存。所以 ③ 作为长期终态是有代价的,它把一个契约缺口固化成了依赖。作为过渡(危害已消除、等 ① 落地)则是合理的,这也是 PR #5272 的定位。
我在派发令里写的是
Fixes #5233。dev 没有照做,按常设条款改成了Part of #5233,并把这个冲突报了上来。dev 是对的,我是错的。 被裁定的写侧半边没有交付,
Fixes一旦合并就会静默关掉一张没做完的卡 —— 而这张卡恰恰还带着一个要送到维护者面前的分叉。常设条款高于单次派发令,dev 越过派发令去执行常设条款是正确行为,不是抗命。记在这里,并会写进后续派发令:派发令与常设条款冲突时以常设条款为准,且请照这次的做法把冲突报上来。Generated by Claude Code
4 remaining items
os-zhuang commentedon Aug 23, 2026
Unlock sweep: blocker cleared, and the spec pin is measured to cover it.
pm:blocked→pm:queue(paired write, same stroke).Two-part release condition, both halves measured:
objectstack-ai/objectstack#9933— CLOSED 2026-08-19T13:24:03Z, via merged PR objectstack#9996packages/spec/CHANGELOG.mdon objectstackorigin/mainlists#9933under## 17.1.0pnpm-lock.yamlon objectuiorigin/mainresolves@objectstack/spec@17.1.0So
ViewMetadataSchemashould now accept acolumnState-only patch, which is exactly the 422 that stopped the write-side half of the ruling here.Authorization: maintainer direct-dispatch channel, this session, verbatim: 「可以解锁却没解 的帮我处理」. PM session
session_0124Qg8rLvpXnQDwCmpKUmaJ. Block age at unlock: 4 days.columnStateas a runtime-only overlay key and making it author-writable are two different spec changes, and only the first shipped. Confirm which one the pin actually gives you before narrowing the write.domain:*— it re-enters the queue lane-less and is invisible to lane-scoped candidate queries until triage stamps it. ⛔ Not stamped here.⛔ Assignee untouched · scope untouched.
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Triage:
domain:ui. The fix lands inpackages/data-objectstack(persistViewPatch) with the call side inpackages/app-shell— the patch-only write the ruling requires. Published library source.The spec-side half of this split already shipped (objectstack#9933, released in
@objectstack/spec17.1.0), which is why this card was returned topm:queueearlier in this session. What remains is objectui's own write-side narrowing — the ui stream.objectui three-stream split (maintainer 2026-08-21):
domain:devxengineering surface ·domain:speccontract surface ·domain:uieverything else (published libraries and apps).Routed via the maintainer direct-dispatch channel, this session, verbatim: 「objectui 有 79 张卡在车道视图之外(占该仓 35%),你帮我分批处理」. PM session
session_0124Qg8rLvpXnQDwCmpKUmaJ. Not a Routine triage fire — the triage seat remains the standing producer ofdomain:*and may re-grade.⛔ pm-state, assignee and scope untouched.
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Claim:
domain:uiexecution seat (seat post #5560), round 22, slot 3 — replacement for #4676, which closed completed with zero code change (its Option C landed upstream; the probe confirmed the pin covers it).session_01EuPCi56cnGyykygi3z9w4mclaude/issue-5233-persist-patch-onlyorigin/mainat9850c6e4edomain:ui· Tier:claude-opus-5@objectstack/spec17.1.0, dispatched at tier there). What remains is objectui narrowing what it writes — no accept-set moves here.Prior claim on this thread is historic, not live:
session_01RV6yuVCxymHYE16PL9vQkE(2026-08-18) ran its dispatch to completion — it fired the rollback clause, escalated, landed PR #5272 asPart of, and returned the card unassigned. Re-read confirms no live claim.⛔ THE TRIAGE LANDING LINE IS INVERTED — measured on
origin/main9850c6e4ebefore dispatch. Triage says "the fix lands inpackages/data-objectstack(persistViewPatch) with the call side inpackages/app-shell". It is the other way round, and the card's own amendment already said so:persistViewPatchis defined atpackages/app-shell/src/views/ObjectView.tsx:738.ObjectView.tsx:766-770—dataSource.updateViewConfig(objectName, viewIdLocal, { ...baseViewDef, ...merged }, { isSavedView: targetIsSavedView }).packages/data-objectstack/src/index.ts:1337-1345holds a docblock that describes the defect ("this adapter persists what it is given"), not the defect itself.Verify all of it yourself; two cards in this round shipped with a stale file attribution.
File surface granted (write):
data-objectstackdocblock currently states the fat write as present-tense fact. If your change makes it false, updating it is in scope; changing that adapter's behaviour is not — it persists what it is given, and that is correct.Serial constraints: ⛔
apps/console/.env.*·examples/console-starter/**held by #5745 (slot 1); ⛔packages/auth/**held by #5731 (slot 2, PR #5764 open).packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.tsalready exists and pins the current fat-write shape — including a regex over everypersistViewPatch(…)call. It is the guard your change must move deliberately, not incidentally. Read it before writing; if it goes red, that is the point, and the update must be argued in the report, not performed silently.Generated by Claude Code
fix(app-shell): store only the patch in a view personalization overlay (
test(#5233): pin the write half, and retire the stale present-tense w…
claude commentedon Aug 23, 2026
os-dev-report
{ "issue": 5233, "status": "done", "branch": "claude/issue-5233-persist-patch-only", "pr": "https://github.com/objectstack-ai/objectui/pull/5772", "premise_still_valid": true, "summary": "PREMISE RE-VERIFIED FIRST (the unlock sweep's instruction, and this card's own history of the rollback clause firing): probed the installed @objectstack/spec 17.1.0 and it now ACCEPTS a columnState-only patch carrying identity (object + viewKind + name), which is the 422 that stopped the write half in August. Controlled both ways in the same run - columnStates, columnstate and zzzNotAKey are still REFUSED with 'Not a `view` body: no recognized `view` key', wrong inner types are refused (so the key is genuinely validated, not stripped), identity-less bodies are refused, and an AUTHORED ViewItem carrying columnState is still refused. So the pin gives the runtime-only admission the ruling asked for, not authorability. Premise holds; proceeded. LANDING SITE: the triage line is inverted, as the claim comment said - persistViewPatch is DEFINED at packages/app-shell/src/views/ObjectView.tsx:738 and the fat write was at :766-771; packages/data-objectstack/src/index.ts:1337-1345 only DESCRIBES the defect. Verified on 9850c6e4e myself. CHANGE: the write now routes through a new exported seam buildPersistedViewBody(baseViewDef, patch, opts). For a SYSTEM view's overlay the body is the patch alone plus viewKind when the active tab carries one - identity, not content, the same line VIEW_OVERLAY_IDENTITY_KEYS draws on the read side, so the write now produces exactly the shape the reader keeps; object, name and the overlay marker are stamped by updateViewConfig itself, and label is deliberately not kept (a stored one is a snapshot of the source view's label). filter, columns, label, type, isDefault - the ruling's own examples of the frozen copy - are all gone. THE ONE JUDGEMENT, and it is measured not argued: updateViewConfig persists through client.meta.saveItem, which is a PUT of the WHOLE DOCUMENT (the adapter's own updateView does read-modify-write precisely because saveItem does not - its 404 path says 'there is nothing to merge onto. Fail loudly instead of emitting the partial write'). So for a SAVED view, whose row IS the view and which has no source underneath it for a copied key to shadow, a patch-only PUT would not narrow the row - it would DELETE the user's view definition. That branch is left byte-identical to the pre-fix write, and ablation C proves the guard bites, failing with 'expected undefined to deeply equal { type: kanban, ...(1) }'. EXISTING ROWS: tolerate-on-read (PR #5272) still holds, and because this write replaces the document the next toggle also STRIPS a pre-fix fat row at rest - so the issue's 'strip on next write' arrives as a consequence rather than a second mechanism. No migration. Both pinned. THE EXISTING GUARD MOVED DELIBERATELY, ARGUED NOT SILENT: ObjectView.overlayPatchOnly.test.ts pinned the fat write as current fact. Its first case is retitled 'a PRE-FIX fat overlay no longer shadows the source view on read' and its fixture comment now says USED TO send - assertions UNCHANGED and still green, still produced by the real adapter write rather than a fixture. The PERSIST_CALLS regex ratchet did NOT move: the fix is inside the callee, so all seven call sites are byte-identical. A SECOND ratchet was added because ablation B showed the gap - reverting only the wiring leaves every behavioural test green. Sibling assumptions checked as asked: ObjectView.overlayFilterRecovery.test.tsx:150 and ObjectView.defaultViewIdentity.test.tsx:238 - neither becomes false; defaultViewIdentity is about the persistence KEY, unchanged, left alone; overlayFilterRecovery's comment claimed the copy in present tense and is tense-corrected only.", "tests": "All at final commit 9a5fce746 (git rev-parse --short HEAD on the tree the union below was run against). SUITES: `pnpm exec vitest run packages/app-shell/ packages/data-objectstack/ --maxWorkers=2` -> 'Test Files 543 passed (543)' / 'Tests 5475 passed | 1 skipped (5476)', 0 failures. After the comment-tense pass, `vitest run packages/data-objectstack/ packages/app-shell/src/views/` -> 'Test Files 342 passed (342)' / 'Tests 3496 passed | 1 skipped (3497)', 0 failures. The guard file alone -> 'Tests 17 passed (17)'. TYPE-CHECK: `pnpm --workspace-concurrency=2 --filter @object-ui/app-shell --filter @object-ui/data-objectstack type-check` -> exit 0 with BOTH script names echoed ('packages/data-objectstack type-check$ tsc --noEmit ... Done', 'packages/app-shell type-check$ tsc --noEmit && tsc -p tsconfig.test.json ... Done'), so neither was a zero-match silent pass. Dependency closure built first: `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` -> exit 0. GATES (each judged by the gate's own printed verdict line, not by a bare $?; every command redirected to a file BEFORE any pipe): check:control-bytes -> 'check-control-bytes: OK (scanned 4803 tracked text file(s); skipped 85 binary)'; check:phantom-deps -> 'Every in-scope import is declared by the package that publishes it.'; check:self-import -> 'No package names itself inside its own src/.'; check:esm-specifiers -> 'Specifier leg: no un-ledgered package emits an extensionless relative specifier.'; check:spec-symbols -> 'spec symbol derivation: 1298 files scanned against 4966 spec export names'; check-changeset-no-major.mjs -> 'No changeset declares a major bump.'; check-changeset-presence.mjs -> '9 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'; check-lint-coverage.mjs -> 'lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total)' (note: that script measures COVERAGE, not verdicts - it is not a substitute for running lint). LINT NARROWED, AND THE NARROWING IS DECLARED WITH ITS THREE PIECES: pnpm lint is `turbo run lint`, i.e. each package's own `eslint .`; ran it for the two packages that contain changed files -> app-shell 935 files / 0 errors / 2560 warnings, data-objectstack 48 files / 0 errors / 368 warnings, plus lint:root -> 0 errors. (1) population is eslint's own selection under the root eslint.config.js, not a list I chose; (2) counts read off --format json; (3) the narrowing excludes nothing because type-aware linting is NOT enabled - the config extends tseslint.configs.recommended, not recommendedTypeChecked, and languageOptions declares no project/projectService, so this diff cannot move the verdict on any file outside those two packages. Warnings are pre-existing no-explicit-any in an any-typed file; eslint exits 0 (no --max-warnings anywhere) and +2 are the new seam's own loosely-typed record signature. CONTROL BYTES over the diff, CONTROLLED: grep -naP over the 10 changed files -> 0 hits, and the SAME instrument over the SAME file list finds 47 persistViewPatch hits, so the zero is a reading. ABLATIONS - three legs, predictions recorded BEFORE running, each committed first, mutated, confirmed on disk by ANCHORED COUNTS IN BOTH DIRECTIONS (the deleted anchor going 1 -> 0 and an injected ABLATION_x marker going 0 -> 1, never a bare git diff --stat and never an editor exit code), run, then restored under `trap ... EXIT INT TERM` with the restore re-confirmed in both directions (marker back to 0, anchor back to 1) and a clean `git status`. NO REBUILD WAS NEEDED OR POSSIBLE TO GET WRONG: the root vitest.config.mts aliases @object-ui/data-objectstack to packages/data-objectstack/src and ObjectView.tsx is imported from source, so no dist can go stale here - and the ablations turning red is itself the proof of that source resolution. LEG A (the seam's overlay branch reverted to the fat spread): predicted 6 red / 11 green, with the saved-view guards and the wiring ratchet SURVIVING - observed exactly that, the six write assertions red. LEG B (seam kept, the WIRING reverted at the call site): predicted 1 red - the new wiring ratchet ONLY, all 16 others green - observed exactly that; this is the gap a behavioural-only suite cannot see and the whole reason the second ratchet exists. LEG C (narrow BOTH branches - the over-strict reading of the ruling): predicted 2 red, both saved-view guards - observed exactly that, failing with 'expected undefined to deeply equal { type: kanban, ...(1) }', i.e. the user's deleted view config quoted by the assertion. Leg C is the control that correctly SURVIVES legs A and B and must die here: it is what says the narrowing stopped where it should. DEMONSTRATION (the pair the dispatch asked for, both legs also pinned as tests): the same scenario run twice through the REAL adapter write, REAL batch read and REAL consumer merge. Pre-fix row keys at rest: _isOverride, columnState, columns, filter, isDefault, label, name, object, type - row.filter frozen at status=open. Post-fix row keys at rest: _isOverride, columnState, name, object, viewKind - row.filter undefined. Read honestly: through objectui's OWN switcher both already behave correctly (PR #5272 doing its job), so the user-visible delta is shown against a reader that does NOT run narrowPersonalizationOverlay - Studio, the server-side switcher - where the pre-fix row still serves filter status=open, label 'All Leads', columns [name,status] against an admin who has moved them to status=qualified / 'All Leads (active)' / [name,status,owner], and the post-fix row serves all three new values. That leg is pinned as 'a reader OUTSIDE objectui - no read-side narrowing - now sees the admin edit too', which runs the pre-fix row FIRST as its own control so the post-fix assertions are a measurement and not a tautology. No pixel render was produced: the user-visible difference here is the effective filter a list queries with, there is no live metadata backend in this container, and dressing a transcript up as a screenshot would be worse than saying so. CI: in_progress at report time (draft PR just opened) - not waited on, per the standing clause.", "open_questions": [], "out_of_scope_findings": [ "filed as #5773 (label `finding`, unassigned, NOT queued): InterfaceListPage hydrates a hollow view out of a personalization overlay row - a latent dependency on the fat write. For a SYSTEM view served hollow by ADR-0017 expansion whose user has personalized it, hydration used to succeed with the overlay's FROZEN copy of columns and now falls through to defaults. Saved views and already-stored rows are unaffected, and objectui's own switcher is unaffected. The full suites are green, which is evidence the case is UNTESTED rather than unreachable - confirming it wants a dogfood run. InterfaceListPage.tsx is outside this card's fence, so it was recorded, not touched." ] }Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
ACCEPT — landing. PR #5772.
Gates by name at 06:33Z (PR started 06:21:55Z): 19
success+ 3skipped= 22. Zeroin_progress, zerofailure. (Build Docs10 s — the path-filtered skip.)Fence: clean, including the one line I most expected to be crossed. 10 files, all inside the grant.
packages/data-objectstack/src/index.tsis +26 −7, which is more than a docblock tense fix looks like — so I read every changed line. All of them begin with*. It is entirely JSDoc; no code moved. The adapter still persists what it is given, and the new prose says so in as many words: "this adapter's behaviour is unchanged by either — it still persists what it is given."The premise check came first, and it is the reason this card could proceed at all
This card's history is a rollback clause firing: in August the ruled write-side narrowing was stopped by a 422, and the unlock sweep explicitly warned that runtime-only admission and author-writability are two different spec changes and only the first shipped.
Probed against the installed 17.1.0, controlled in both directions in one run:
columnState-only patch carrying identitycolumnStates/columnstate/zzzNotAKeyviewbody: no recognizedviewkey"ViewItemcarryingcolumnStateThat last leg is the one that matters: it proves the pin gives runtime-only admission, not authorability — precisely the distinction the ruling drew and the sweep flagged. The premise holds, and it holds for the right reason.
The judgement of the round: a patch-only PUT would have deleted the user's saved view
updateViewConfigpersists throughclient.meta.saveItem, which is a PUT of the whole document — the adapter's ownupdateViewdoes read-modify-write precisely becausesaveItemdoes not, and its 404 path says "there is nothing to merge onto. Fail loudly instead of emitting the partial write."So for a saved view, whose row IS the view and which has no source underneath it for a copied key to shadow, narrowing the write would not have narrowed a row — it would have destroyed the user's view definition. That branch is left byte-identical to the pre-fix write, and ablation C proves the guard bites, failing with
expected undefined to deeply equal { type: kanban, …(1) }— the deleted view config quoted by the assertion.The ruling says "store the patch only". A literal reading would have shipped data loss. This is the distinction between honouring a ruling's observable requirement and honouring its wording, and it was found by reading what
saveItemactually does rather than by reasoning about what "patch" means.The existing guard moved deliberately, and the gap it left was found by ablation
ObjectView.overlayPatchOnly.test.tspinned the fat write as current fact — I flagged that a guard rewritten to match new behaviour is indistinguishable from a guard deleted. What happened instead:PERSIST_CALLSregex ratchet did not move at all, because the fix is inside the callee and all seven call sites are byte-identical.Three ablations, predictions recorded before running, all three observed exactly: A (seam reverted to the fat spread) 6 red / 11 green; B (wiring reverted) exactly 1 red — the new ratchet alone; C (both branches narrowed — the over-strict reading) exactly 2 red, both saved-view guards. Leg C is the control that correctly survives A and B and must die here: it is what says the narrowing stopped where it should.
The demonstration is read honestly, which is why it is worth something
The pair I asked for is there — but through objectui's own switcher both pre- and post-fix rows already behave correctly, because PR #5272's read-side narrowing is doing its job. Rather than dress that up, the user-visible delta is shown against a reader that does not run
narrowPersonalizationOverlay— Studio, the server-side switcher — where the pre-fix row still servesfilter: status=open,label: 'All Leads',columns: [name,status]against an admin who has moved them, and the post-fix row serves all three new values. The pre-fix row runs first, as its own control, so the post-fix assertions are a measurement rather than a tautology.And no pixel render was produced, with the reason stated: the user-visible difference is the effective filter a list queries with, there is no live metadata backend in this container, and dressing a transcript up as a screenshot would be worse than saying so. Correct.
Row keys at rest, before and after:
_isOverride, columnState, columns, filter, isDefault, label, name, object, type→_isOverride, columnState, name, object, viewKind.filter,columns,label,type,isDefault— the ruling's own examples of the frozen copy — are gone.No migration, and none needed: because the write replaces the document, the next toggle also strips a pre-fix fat row at rest. The card's "strip on next write" arrives as a consequence of the fix rather than as a second mechanism. Both halves pinned.
Sibling assumptions checked as asked:
overlayFilterRecovery.test.tsx:150is tense-corrected only;defaultViewIdentity.test.tsx:238is about the persistence key, unchanged, and correctly left alone.Filed out of scope: #5773 —
InterfaceListPagehydrates a hollow view out of an overlay row, a latent dependency on the fat write. The observation that makes it worth reading: "the full suites are green, which is evidence the case is UNTESTED rather than unreachable." Outside this card's fence, so recorded rather than touched.Flipping ready → auto-merge (SQUASH). Landing verification against
origin/mainfollows.Generated by Claude Code
fix(data-objectstack,app-shell): a view overlay contributes only the …
fix(app-shell): store only the patch in a view personalization overlay (