Repository navigation
fix(app-shell): take a served metadata draft as-is instead of spreading it over the published layer (objectui#10765) - #10775
Conversation
…ng it over the published layer The metadata-admin editor rebuilt its draft as the served draft spread over `layered.effective` on load, after each draft save and after publish; the Studio design surface did the same when opening an app, a surface, an object or a flow, and the package OWD overview when reading and re-saving an object's draft. `effective` is the published layer and a spread cannot express deletion, so a key the author cleared in the draft came back from the published layer on the next refresh and the next save sent it again. The server stores a `?mode=draft` body raw and serves the `?state=draft` row raw, and every draft writer sends a whole document, so each site now takes the served draft as-is and falls back to the baseline only when no draft exists. `config.toDraft` still runs on whatever is taken, now on the post-publish refresh too. Pins: one server double that stores draft rows raw and serves `effective` as the active row only; the report joined-clear case across save, refresh and publish; a generic deleted key across load, save, refresh and publish with the inherited `type` as the control; the four design-surface sites and the two OWD-panel sites, one red-on-base assertion each. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: ① Derived judgmentsH1, verified from objectstack
H2. Nine sites. Base PackageOwdOverviewPanel widening. Pins, re-run in a detached worktree at head, Siblings. The 57 files the dev names (glob re-derived: Type-check. Closure build ② Semver level
Changeset, sentence by sentence: true against head and the server source; "stores a PR body: the H1 table verified symbol by symbol (loose only at Pending changesets naming the touched files: ③ Boundary flags
Implemented-by: VERDICT: FAIL What stops it — one wording item, no code change owed:
Non-blocking, for the seat's next push if any: the two stale merge comments in |
…say what the draft reads do now The pending objectui#10746 changeset stated a reach limit this branch removes: a report whose PUBLISHED version was bound got its cleared keys back in the draft after the first draft save, through the host's draft-over-baseline merge. Its sentences are now scoped to that change and a dated note (objectui#10156 / objectui#10392 form) states what holds now: the clear survives the refresh after the save and publish; a report already saved joined with stale keys is still not repaired on load. Frontmatter unchanged. The objectui#10765 changeset names the reach limit it removes. Comment-only: the Data pillar's projection-baseline comment and three PackageOwdOverviewPanel comments (the OwdRow docblock, the list-load and the save) said the body was merged over the pending draft; they now say the draft is read as-is, else the layered baseline. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Narrow round-2 review, run in-seat by the dispatching seat, of the delta ① Derived judgments
② Semver level
Observation, not blocking: the objectui#10765 changeset still says the editor falls back "only when no draft exists". The code falls back when no draft body was READ, which includes a failed read. The replacement PR body now states that precisely (H2 and the Acceptance note on the pre-existing autosave hazard), and round 1 passed the changeset sentence by sentence. ③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #10765
Clause-②: no — the Studio editor's draft rebuild; no declared surface moves
What changed
Every editor that rebuilt its draft as the served draft SPREAD over
layered.effectivenow takes the served draft as-is and falls back to the baseline (effective, thencode) only when no draft body was read (see H2 for what that covers).effectiveis the PUBLISHED layer and a spread cannot express deletion, so a key the author cleared in the draft came back from the published layer on the next refresh and the next save sent it again.Nine sites in three files of
@object-ui/app-shell, one spelling each (served draft ?? baseline):ResourceEditPage.tsx— the load effect (rawInitial), the post-save refresh indoSave(rawFresh), the post-publish refresh indoPublish(rawFresh;config.toDraftnow runs there too, as the other two refreshes already did).StudioDesignSurface.tsx— the Interfaces pillar's app load (appDraft), its surface-leaf load (setDraft), the Data pillar's object load (setObjDraft), the Automations pillar's flow load (setDraft). The triage named the surface-leaf site; the other three are the same spread in the same file, and the triage's rule is "no per-type exception".PackageOwdOverviewPanel.tsx— the list load that derives each row's OWD values and the save that splices the edit into the object's body. Bounded in-place exemption, declared below.Three pin files, one changeset (
'@object-ui/app-shell': patch), and a prose amendment to the pending objectui#10746 changeset, whose reach sentence this change made false (declared in Acceptance notes). No objectstack change.H1 — the premise: a served draft is always a whole document
Answered from objectstack
origin/mainsource (packages/metadata-protocol/src/protocol.ts,packages/metadata-protocol/src/sys-metadata-repository.ts,packages/rest/src/rest-server.ts), cited by symbol. A stock boot is NOT MEASURED:/home/user/objectstack/node_modulesis empty in this container.saveMetaItemends inrepo.put(ref, request.item, { state: mode === 'draft' ? 'draft' : 'active', … }), andSysMetadataRepository.putstoresmetadata: JSON.stringify(body)withbody = spec ?? {}— no read-modify-write against the existing row (the existing row is read only for the parent hash, theskippedshort-circuit and, on a draft save,package_idinheritance). Before the put,saveMetaItemnever merges the prior draft or the active row into the body. It subtracts only server-owned keys, in three strips:stripReadDecorations(the read-time decorations),stripDerivedProvenance(_packageId/_packageVersion/_provenance, which the read side recomputes) andstripServedObjectColumns(the system columns the server injects into a served object). None of the three reads another row. Everything else it does ADDS to the body or respells it, and the one pass that reads a stored row copies back redacted credential values only:normalizeViewMetadatagrafts a view's missingviewKind/object/labelfrom the registry baseline (viewIdentityPatch, "The overlay's own fields always win");withDeclaredPageTypeDefaultfills an absent pagetypewith the schema's declared default;carryForwardRedactedCredentialsrestores a datasource's redacted credential values from the stored draft-or-active row;graftNormalizedOperators/graftFoldedFormSectionsrewrite spellings the schema already normalised.getMetaItemwithstate: 'draft'reads thestate: 'draft'row (org-scoped, then env-wide; package-scoped, then global),convertStoredItems it, and returnsdecorateMetadataItem(type, governServedObject(type, item)); when the row is absent it throwsNO_DRAFT(404) — "Draft reads stop here — they intentionally do NOT fall through to the runtime registry". On this strict pathdecorateMetadataItemadds_diagnosticsonly;_draft: trueis stamped on thepreviewDraftsarms, not here.extractDraftBodystrips both on the client (the spec'sMETADATA_READ_DECORATIONS, throughstripReadDecorations).effectivenever sees a draft.getMetaItemLayeredcomposeseffectiveasgovernServedObject(overlay ?? code)whereoverlayis thestate: 'active'row only.promoteDraftreads the draft row and callsthis.put(ref, draft.body, { state: 'active', opType: 'publish', … }), then drains the draft row.PUT /meta/:type/:name(?mode=draftmaps to{ mode: 'draft' }and the body is passed asitem); a grep ofpackages/rest/src/rest-server.tsfor a PATCH route on/metafinds none.@objectstack/client'smeta.saveItem(…, { mode: 'draft' })sends the caller's body.packages/cli/srcholds nomode: 'draft'writer.Every writer of a
mode: 'draft'body, enumerated (git grepover both trees at the stated refs, non-test source):objectui (
63835d998f):ResourceEditPage.doSave—itemToSave=config.fromDraft ? fromDraft(draft) : draft: the whole editor draft (no type config definesfromDrafttoday).StudioDesignSurfaceInterfaces pillar —doCreateApp(buildAppSkeleton, whole), surfacedoSave(draft, whole),doNavSave({ ...appDraft, navigation: cleanedNav }, whole).StudioDesignSurfaceData pillar — object create (buildObjectSkeleton, whole),doSave(objDraft, whole), the field-order save ({ ...objDraft, fields }, whole).StudioDesignSurfaceAutomations pillar — flow create (buildFlowSkeleton, whole),doSave(draft, whole),toggleEnabled({ ...draft, status }, whole); Access pillar — permission create (buildPermissionSkeleton, whole).ObjectHooksPanel—save(draft, a whole hook item) andaddHook(fresh, whole).PackageOwdOverviewPanel.doSave—next, the whole object body with the two OWD keys set or deleted.PermissionMatrixEditor—mergePermissionSlice(base, payload, scope)withbase = fresh.effective ?? payload: a whole permission-set body (draft mode only when apackageIdis bound).runtime-metadata-persistence.ts—persistRuntimeMetadata/createRuntimeMetadataPUT thebodytheir callers hold: the whole report fromReportConfigPanelthroughReportView.saveSchema, the whole view from the runtime view persisters.@object-ui/data-objectstackObjectStackAdapter.updateView(object, view, partial)— the one API that TAKES a partial: it reads the pending draft (or the published view) and savesmergeViewPatch(current, partial, …)={ ...current, ...partial, name, object }, a whole document.objectstack (
4df101c383):10. REST
PUT /meta/:type/:name?mode=draft— passes the request body raw (above).11.
ObjectStackClient.meta.saveItem— the caller's body, raw.12.
migrateStoredMetadata --apply(protocol.ts,source: 'migrate-stored') — re-saves the CONVERTED WHOLE stored row withmode: state === 'draft' ? 'draft' : 'publish'.No writer sends a subset of a document. The one caveat is not a writer but a door: any REST client can PUT a hand-written partial body with or without
?mode=draft, and the server stores it as the whole draft or the whole published body alike — the editor then shows exactly what the server holds, which is the truthful reading. ⇒ Premise holds: always full; the "can be partial" stop in the triage did not fire, so noneeds_decision.H2 — why the merge existed
The comment at the load site said the spread kept "inherited fields like
type" for ADR-0047visibleOnpredicates, treating the draft as a "partial draft overlay". Atypeis absent from a served draft only when the writer omitted it, and H1 shows no writer sends a subset: the editor's own saves carrytypebecause the editor's draft was loaded fromeffectiveor the previous draft, both of which carry it, and for a page the server fills a missingtypeon the way in (withDeclaredPageTypeDefaultinsaveMetaItem) and on the way out (convertStoredItem). The inheritance the spread protected is inside the draft. The pins' CONTROL assertions readtypeback from the served draft at every step.When the baseline is taken, as the code reads it. Every site reads
client.getDraft(…).catch(() => null).MetadataClient.getDraftsits onreadItemResponse, which returnsnullon a 404 (NO_DRAFT) and throws on any other non-OK answer; the.catchthen folds a 403, a 5xx or a network failure intonulltoo. So the baseline is taken when no draft body was READ, which covers "no draft exists" and also "the draft read failed". Base did the same at every site, and this change does not touch it. The hazard that leaves is named in Acceptance notes.H3 — the fix, per site
ResourceEditPageload effectdraftReal ? { ...baseline, ...draftReal } : baselinedraftReal ?? baselineResourceEditPage.doSavepost-save refreshdraftReal ? { ...freshBaseline, ...draftReal } : freshBaselinedraftReal ?? freshBaselineResourceEditPage.doPublishpost-publish refreshtoDraftdraftReal ?? freshBaseline, thenconfig.toDraftStudioDesignSurfaceInterfaces — app load (appDraft)appDraftBody ? { ...eff, ...appDraftBody } : effappDraftBody ?? effStudioDesignSurfaceInterfaces — surface leaf loadbody ? { ...baseline, ...body } : baselinebody ?? baselineStudioDesignSurfaceData — object load (setObjDraft)draftBody ? { ...baseline, ...draftBody } : baselinedraftBody ?? baselineStudioDesignSurfaceAutomations — flow loaddraftBody ? { ...baseline, ...draftBody } : baselinedraftBody ?? baselinePackageOwdOverviewPanellist load (body)draftBody ? { ...baseline, ...draftBody } : baselinedraftBody ?? baselinePackageOwdOverviewPanel.doSave(next){ ...baseline, ...(draftBody ?? {}) }{ ...(draftBody ?? baseline) }config.toDraftstill runs on whatever is taken at sites 1 and 2, and now at 3; it is inert today (no registered type config definestoDraft/fromDraft— theregistry.tsinterface declares them,builtinComponents.tsxstates the view previews readdraft.configdirectly and use none).Also checked, unchanged:
EmbeddedItemEditor.doSavereadslayered.effective ?? layered.codeALONE, splices the embedded item in and saves in publish mode — no draft is spread.ResourceEditPage.doResetanddoDiscardDraftread the baseline alone after deleting the row — correct, unchanged.H4 — edges
ResourceEditPagefile and the Interfaces CONTROL in the design-surface file —toStrictEqual(published)).itemToSave, and the refresh shows exactly that (pinned).promoteDraft→put(ref, draft.body, { state: 'active' })); the triage's checklist item ("the published body lacks the cleared key") is pinned in bothResourceEditPagecases against the server double.Pins and ablations
Three pin files (10 tests), all green on head
85f1b9e91a(re-run this round under the verify lock:Test Files 3 passed (3),Tests 10 passed (10),VERDICT command-exit 0); 7 red on base with the three source files at63835d998fand the pin files present (the 3 that stay green are the controls). The base leg and the ablations below were measured at5f3b9c73fd; the round-2 commit85f1b9e91achanges comments and changeset prose only (a grep of its.ts/.tsxhunks for any changed line that is not a comment answers none), so no statement they read moved.ResourceEditPage.servedDraftTakenAsIs-10765.test.tsx(4): mounts the REALMetadataResourceEditPagewithregisterBuiltinInspectors()so the REALReportDefaultInspectortype picker is the producer; a server double stores draft rows raw, serveseffectiveas the active row only, promotes on publish, throwsNO_DRAFTwhen no row. THE TRIAGE PIN: bound published matrix report → pick "Joined" → first PUT clean → post-save refresh has none of the six keys undertype: 'joined'→ an unrelated edit's second PUT has none → Publish → published body and post-publish editor draft have none. GENERIC: a published page'sdescriptiondeleted in the draft stays deleted on load, across the next save and refresh, and through publish, withtypeas the CONTROL. Two EDGE / CONTROL cases (no draft; first draft save is the whole document).StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx(4):InterfacesPillar(applabeldeleted in the draft → heading readsacme_app · Navigation; pagedescriptiondeleted → the canvas draft lacks it),DataPillar(objectsharingModelset back to unset → Settings shows unset),AutomationsPillar(flowdescriptiondeleted → canvas draft lacks it, and the enable switch's save carries the draft alone). Plus the Interfaces CONTROL.PackageOwdOverviewPanel.servedDraftTakenAsIs-10765.test.tsx(2): LIST LOAD (a cleared model reads unset) and SAVE (an internal-only edit does not resurrect the publishedexternalSharingModelordescription).Base leg, quoted:
Tests 7 failed | 3 passed (10);AssertionError: after the post-save refresh the editor draft has no dataset: expected { name: 'pipeline', …(8) } to not have property "dataset";expected { name: 'home', …(5) } to not have property "description";expected 'private' to be '';Unable to find an element with the text: acme_app · Navigation. Restore proven:git diff HEADempty, each path's blob equal to HEAD.Per-site ablations on the committed head through objectstack's
scripts/ablation-replace.mjs(anchor hits exactly once, blob change proven, restore proven per leg,diff-HEAD-lines=0after each), prediction written before each run:draftReal ?? baseline→ spreaddraftReal ?? freshBaseline(save) → spreaddraftReal ?? freshBaseline(publish) → spread4 passed— none, as predicted (see Acceptance notes)appDraftBody ?? eff→ spreadbody ?? baseline→ spreadsetObjDraft(draftBody ?? baseline)→ spreadsetDraft(draftBody ?? baseline)→ spreadbody = draftBody ?? baseline→ spread2 failed (2)— LIST LOAD and SAVE: MORE than predicted; the list-load merge also feeds the row'sexternaledit, so the save wrote the resurrectedexternalSharingModel{ ...(draftBody ?? baseline) }→ spreadDirection observed as predicted at eight sites; site 8 reddened one assertion more than predicted (reported as observed); site 3 reddened nothing, as predicted.
Gates (local; objectui has no
dispatch-gates.mjs— derived frompackage.jsonand.github/workflows)Round 2, at head
85f1b9e91a, run after the final commit:Test Files 3 passed (3),Tests 10 passed (10),VERDICT command-exit 0.node scripts/check-changeset-presence.mjs: exit 0 — ✅ "6 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/10765-draft-merge-deletion.md".node scripts/check-changeset-no-major.mjs: exit 0 — ✅ "No changeset declares amajorbump."node scripts/check-changeset-claims.mjs: exit 0 (report-only). It names one pending changeset,6681-declared-lazy-marketplace-routes.md, for spellingStudioDesignSurface.tsx; that is a chunking claim, and this branch changes no import there.node scripts/check-changeset-overwrite.mjs: exit 0 (report-only). It reportsM .changeset/10746-joined-report-clears-binding.md, declared@object-ui/app-shell: patchat base and now: the intended prose correction declared in Acceptance notes (the gate's own case 2).pnpm check:pending-changeset-literals: exit 0 — ✅ "No test source names a pending changeset."pnpm check:new-line-citations: exit 0 —VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0.pnpm check:control-bytes: exit 0 — ✅ "OK (scanned 8957 tracked text file(s); skipped 85 binary)". The role file's control-byte grep over the four files this round edits: no match.node scripts/check-governed-queue-guard.mjs --testover the branch's eight paths: ✅ NOT GOVERNED.Round 1, at
5f3b9c73fd, not re-run: round 2 changes comments and changeset prose only.turbo run build --filter='@object-ui/app-shell^...' --concurrency=2under the verify lock:Tasks: 28 successful, 28 total,VERDICT command-exit 0.pnpm --filter @object-ui/app-shell type-check(tsc --noEmit && tsc -p tsconfig.test.json):VERDICT command-exit 0;tsc -p tsconfig.test.json --listFileslists the three pin files (3 hits among 4677 files).ResourceEditPage.*,PackageOwdOverviewPanel*,ObjectSettingsPanel.owdAgreement,StudioDesignSurface.*,DataPillar.*,ObjectHooksPanel.*,ReportDefaultInspector.*test):Test Files 57 passed (57) · Tests 271 passed (271),VERDICT command-exit 0.pnpm check:test-path-roots,pnpm check:vi-mock-specifiers,pnpm check:vi-mock-inherit,pnpm check:vi-mock-override-shape: ✅ OK each..tsxfiles: exit 0, 0 errors, 76 pre-existing warnings, 0 of them on a line this branch changed (checked against the committed hunks).NOT MEASURED locally, CI-owned:
pnpm lint(repo-wide), the test shards,Build & E2E,check:i18n-*(not()key added or changed),Governed Surface Queue Guard. CI on85f1b9e91awas still running at the report read (every completed run a success or an expected skip); its converged state is the seat's reading.Serial
Branch fast-forwarded to
63835d998f(PR objectui#10762 landed) before the first edit, so the pins use itscommitTypeas the real producer. Re-derived at round 2 against a freshorigin/main, fetched into a private ref:17b323e5ca, +4 over BASE (objectui#10754, objectui#10766, objectui#10763, objectui#10774).git diff --stat 63835d998f 17b323e5caover the branch's eight paths is empty, andgit merge-tree --write-tree HEAD 17b323e5caexits 0, clean, treefcd7226887. No merge commit needed. Open PRs were not re-read this round; the seat's review read 10 and found none onResourceEditPage.tsx,StudioDesignSurface.tsx,EmbeddedItemEditor.tsx,PackageOwdOverviewPanel.tsxorPermissionMatrixEditor.tsx.Acceptance notes
.changeset/10746-joined-report-clears-binding.md(objectui#10746, pending, same release) ended with a reach sentence this change made false: that a report whose PUBLISHED version was bound gets the cleared keys back in the draft after the first draft save until publish, through the host's draft-over-baseline merge (objectui#10765). Its sentences are now scoped "At this change", and a⚠️ **Dated note, 2026-09-27 — that merge has since been removed — objectui#10765.**in the objectui#10156 / objectui#10392 form states what holds now: the clear holds across the refresh after the save and through publish, because the editor takes a served draft as-is; a report already savedjoinedwith stale keys is still not repaired on load, and the author deletes them from the JSON tab. The frontmatter is byte-identical: md5 of its three lines is4e8ce55021fe3b14329c8b4cb3f9f354at base63835d998f, at5f3b9c73fdand at85f1b9e91a.check-changeset-overwritereports the file as modified; that is this intended correction. The objectui#10765 changeset gains one sentence naming the reach limit it removes.git grepof.changeset/*.mdat fresh main17b323e5caand at this head, forlayered.effective,draft-over,spread over,resurrect,objectui#10765andpublished layer). Outside the objectui#10746 and objectui#10765 files the only hits are "resurrect" in four unrelated senses:4919-retire-mobile-overrides.md(a declaration),6799-authored-columns-fls.md(a grid column),7499-gantt-non-axis-floors-omitted.md(a gantt config) andactive-org-clear-removal-failure-5731.md(a storage entry). None is falsified. A wider read for draft-over-baseline wording found7603-resource-edit-read-decoration-strip.mdand8181-draft-envelope-read-decoration-strip.md. Both describe the merge in the past tense, as the state before their change. 7603's "the chokepoint all three merge sites go through" still holds of its subject, since all three sites still go throughextractDraftBody; "merge sites" is that change's label. Nothing else needed reconciling.StudioDesignSurface.tsxand theOwdRowdocblock inPackageOwdOverviewPanel.tsx. Fixed two more inPackageOwdOverviewPanel.tsxwith the same stale wording: the list-load comment ("merged body (layered ∪ its draft)") and the save comment ("fresh merged body"). The file header's "(published ∪ pending drafts, the same merge the pillars do)" is about which OBJECTS are listed, a union of lists, so it stays..catch(() => null)on every draft read folds a failed read (403, 5xx, network) into "no draft", so the site shows the published baseline as the editable draft. The next save then writes that baseline-derived body as the draft, whether it is manual or the metadata-admin auto-save after an edit, and it replaces the pending draft the failed read never fetched. Base had the same shape at every site; this change neither adds nor removes it. Not filed. carrier: none.PackageOwdOverviewPanel.tsxwas not in the dispatch's file surface. Same defect class (a served object draft spread overeffective), mechanical fix in the pinned shape, no other claim on the file (open-PR file lists read), same gate family (app-shell vitest / type-check / changeset), no new verification surface. Pinned with two red-on-base assertions; the file surface is stated here.promoteDraft→dropPromotedDraftRow), sogetDraftanswersNO_DRAFTafter a publish and the spread had no draft to resurrect from; a draft that survives (a drain failure, or an autosave racing the publish) is a whole document equal to or newer than the promoted body. The spread there was inert in practice, not wrong — and it is now the same spelling as the other two refreshes.config.toDrafton the post-publish refresh: added for uniformity; inert today (no type config defines the hook).PermissionMatrixEditor's package-scoped save merges the edited slice intofresh.effective(the PUBLISHED body) and never reads the pending draft, so a slice save could overwrite other pending draft edits of the same permission set. Different mechanism from this card's. carrier: none.EmbeddedItemEditorreads the baseline alone and saves in publish mode (nomode: 'draft'), by design of the embedded flow; noted because the dispatch asked.studio-authoring.draft-publish-lifecycle) owes an editor-driven step (clear a key over a published version, save, refresh, publish); that checklist lives in objectstack and is not touched here.Generated by Claude Code