fix(studio): hoist the draft-envelope reader and strip read decorations at every consumer - #8211
Merged
os-justin merged 1 commit intoSep 7, 2026
Conversation
…ns at every consumer
`client.getDraft()` serves a DECORATED body: the draft branch stamps
`_draft: true` and `decorateMetadataItem` attaches `_diagnostics` for any type
with a registered Zod schema. The spec names both READ-TIME decorations because
a served body is not a valid input to the schema that produced it until they
come off (`METADATA_READ_DECORATIONS`).
objectui#7603 taught ONE site to strip. It could only teach one, because
`extractDraftBody` was implemented four times (three verbatim copies plus a
hand-rolled one in ObjectHooksPanel) and six more consumers unwrapped the
envelope inline. Ten readers, one of which knew the rule. Adding a tenth strip
would leave the eleventh copy free to omit it, so the reader is hoisted instead:
`extractDraftBody` now lives in `@object-ui/data-objectstack`, beside the
`getDraft` whose envelope it decodes.
Measured, per site, before writing anything:
- The publish-review diff is the LIVE half. `DraftChangesPanel` compares the
published body against the draft body key by key, and the framework
decorates those two reads asymmetrically -- only the draft branch stamps
`_draft`. So the sheet listed `_draft` under "Also changed:" on every entry
with a published counterpart, with `_diagnostics` alongside whenever the two
read-time verdicts differed: framework-internal keys shown to the author as
their own edits, on the screen where they decide whether to publish.
- Six sites merge a decorated body into a document they write back through
`save(..., { mode: 'draft' })`. Today's server absorbs that -- `saveMetaItem`
strips read decorations on ingress, before its own schema gate -- so nothing
400s. That is a mitigation on the far side of the wire, not a licence for
this client to emit a body its own spec calls invalid.
- No CLIENT validation gate is reached: `validateMetadataDraft` has exactly
one caller, and objectui#7603 already fixed it.
- The two preview lints do not move their verdict on a decorated body
(control: an object with no `sharingModel` fires `security-owd-unset` in
both directions). Stripped anyway, so no verbatim copy of the unwrap is left
behind without the rule.
The key list is the spec's exported `stripReadDecorations`, never a second
hand-maintained copy here. The presence verdict still runs BEFORE the strip, so
removing our own annotations can never turn a served draft into "nothing
pending". The ADR-0010 protection envelope (`_lock`, `_provenance`,
`_packageId`, `_packageVersion`) is deliberately untouched: the closed schemas
declare those keys. No schema was loosened and no gate was taught to tolerate
`_diagnostics`.
Refs: objectui#8181
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Contributor
✅ 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
|
os-justin
marked this pull request as ready for review
September 7, 2026 01:51
os-justin
deleted the
claude/issue-8181-draft-envelope-read-decoration-strip
branch
September 7, 2026 02:07
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of objectui#8181. Draft on purpose — the PM lands it.
Deliverable 1 first: the measurement the card deliberately did not claim
The card established only "no strip is present at these sites". It did not establish whether a
decorated body reaches a gate or a write. Measured before any code was written, and the population was
re-derived rather than taken from the card — it turned out to be ten readers, not four.
views/metadata-admin/ResourceEditPage.tsxextractDraftBodyvalidateMetadataDraftcallerviews/studio-design/StudioDesignSurface.tsxextractDraftBody(4 call sites)save(..., mode draft)views/studio-design/PackageOwdOverviewPanel.tsxextractDraftBody(2 call sites)views/studio-design/ObjectHooksPanel.tsxdraftBodyhooksto editor draft tosave('hook')views/metadata-admin/PermissionMatrixEditor.tsxinline unwrapviews/runtime-metadata-persistence.tsunwrapDraftBody— the card's unmeasured leadRuntimeDraftBarresumepreview/DraftChangesPanel.tsxunwrapItem— on nobody's listpreview/capabilityLint.tsinlineunwrap— on nobody's listpreview/securityPostureLint.tsinlineunwrap— on nobody's listpackages/data-objectstack/src/index.tsunwrapViewDraft— different package, on nobody's listupdateViewmerges thensave('view')No gate is reached
validateMetadataDraft(andhasClientValidator(each have exactly one production call site in thewhole repo, both in
ResourceEditPage, which PR #8182 already fixed. On the PM's stated criterion —re-grade to p2 the moment a decorated body is shown reaching a gate — the answer is no.
A write IS reached, at five sites, on the success path
…and the write does not fail today only because the server launders it.
saveMetaItemcallsstripReadDecorations(request.item)at the top of the method, with a comment saying it is placed firstso "the destructive-change diff, the schema gate, the authoring gate and the persisted body all see the
same document" (objectstack#4326).
PUT /meta/TYPE/NAMEhands the body straight to it. So: no 400.That is a mitigation on the far side of the wire, not a licence. Measured against the bundled spec
17.2.0, for every type these sites write:
(
viewis absent because I could not build a fixture its union accepts, not because it behavesdifferently — its bare body already REJECTs, so the leg does not isolate. Stated rather than papered over.)
One site IS a live, user-visible defect — and it is site 7, which nobody had listed
DraftChangesPanelfetches each entry twice, published thenstate=draft, andcomputeChangeDetailreports every top-level key whose value differs. The framework decorates those two reads
asymmetrically: inside
getMetaItem, the draft branch stamps_draft: trueon the row beforedecorateMetadataItemruns, and the published branch stamps nothing. So the pending-changes sheetlisted
_draftunder "Also changed:" on every entry that has a published counterpart, with_diagnosticsalongside whenever the two read-time verdicts differed.A framework-internal key rendered to the author as one of their own edits, on the screen where they
decide whether to publish. No failure arm, no schema gate, no rare path — wrong on the happy path,
every time.
_packageIdas a candidate second asymmetry and disproved it:getMetaItemstamps iton both branches, and it is deliberately not a read decoration (the closed schemas declare it), so it
must not be stripped and it does not pollute the diff.
Two sites measured NOT to matter
validateSecurityPostureandvalidateCapabilityReferencesreturn byte-identical findings with andwithout decorations. Control that fires: an object with no
sharingModelproduceserror security-owd-unsetin both directions, so the rules really ran. Stripped anyway — see below.The fix: one reader, not ten strips
Zone 3's framing is right, and the measurement supports it: this is not "N consumers forgot". One
helper was copy-pasted and only one copy learned the rule. Adding a tenth strip leaves the eleventh copy
free to omit it.
extractDraftBodynow lives in@object-ui/data-objectstack— beside thegetDraftwhose envelope itdecodes, because the unwrap is part of that method's contract, not a detail of any one view. The four
byte-identical strict copies (rows 1-4) were deleted and now import it;
getDraft's own docblock pointsat it and says not to hand
.itemto a gate or a write yourself.Rows 5-10 keep their own presence semantics and gain the spec strip. Their tolerance is load-bearing,
not defensive, and the split is principled rather than arbitrary:
unwrapDraftBodyandunwrapViewDraftare fed byMetadataClient.get(), which already unwrapped the envelope(objectui#4271), so the bare-body limb is their normal path. They split on which client method feeds
them, not on what they mean — and all of them strip now.
Rows 8-9 were fixed despite measuring clean, deliberately: the alternative is leaving two more verbatim
copies of the unwrap that omit the rule, which is exactly the shape this card exists to end. The comment
there says so, including that it is defence in depth and not a fix.
The reusable asset is the spec's exported
stripReadDecorations, exactly as #8182 used it. No secondhand-maintained key list. No schema loosened. No gate taught to tolerate
_diagnostics. The presenceverdict still runs before the strip, so removing our own annotations can never turn a served draft
into "nothing pending", and the ADR-0010 protection envelope (
_lock,_provenance,_packageId,_packageVersion) is untouched because the closed schemas declare it.Clause-2: does this fire on the accept/reject leg?
Yes, and on the same leg as #8182 — but at a different door. #8182's leg was a client gate that
began accepting bodies it previously rejected. Here:
_draftreported as a changed key on every entry. After: reported only when the author actually changed
something. The pin
still reports nothing at all when only the decorations differmoves the sheetfrom
Also changed: _diagnostics, _drafttoNo differences detected.400 that does not happen: the server's ingress strip absorbs it. The leg that fires there is the
spec's accept/reject verdict on the body this client emits, measured in the table above.
Tests
New / extended, all reddened by ablation:
packages/data-objectstack/src/draft-envelope.test.ts— the hoisted reader: strips both decorations,keeps the ADR-0010 envelope, does not mutate the response, and a draft carrying only decorations
still reads as pending (
{}, truthy) rather thannull.packages/app-shell/src/preview/__tests__/DraftChangesPanel.readDecorationStrip.test.tsx— the livedefect. Drives the real panel through real fetch routes. Control: a genuine
labelchange must stillbe reported, so the absences mean something.
packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.readDecorationStrip.test.tsx—proves the hoist reaches a write, through the real component's real save.
packages/data-objectstack/src/updateView.draft.test.ts— the adapter's read-edit-write round trip.packages/app-shell/src/views/runtime-metadata-persistence.test.ts— both limbs of the tolerant sibling.Ablation
Mutated all five strip sites (removing the strip, keeping the unwrap), proved the mutation reached disk
with anchored
grep -cbefore/after — five markers, zero strips left — ran the pins, restored withgit checkout HEAD -- PATH, and proved the restore by state:git hash-objectequalsgit rev-parse HEAD:PATHfor all four files,git diff HEADempty,git statusempty. The scriptcarries
trap RESTORE EXIT INT TERMwith absolute paths.Every new assertion fails without the strip; the 37 pre-existing assertions in those files still pass,
which is what shows the mutation was surgical.
Verification runs
Both changed packages were run in full — 1108 test files, 10798 tests, zero failures.
vitest packages/data-objectstack/ + app-shell/{preview,views,services}vitest packages/app-shell/(everything else in the package)type-checkboth packages (after building app-shell's closure)turbo run lintboth packageseslint . --no-inline-configrepo-wide, 4399 filesmainexits 1 on this command toocheck:control-bytes, unreferenced-sources, phantom-deps, self-import, vi-mock-specifiers, vi-mock-inherit, handler-key-reads, governed-queue-guard, docs-route-closure, pre-install-import-graph, side-effects-array, esm-specifiers, spec-symbols, i18n-keys, i18n-drift, i18n-dead-keys, lint:coveragecheck:changeset-presence,check:changeset-no-majorcheck:readme-exports,check:eager-closure,check:spec-floorspnpm buildfirst"), not a verdict. Declared to CI.readme-exportsjudges README-to-exports, so adding an export cannot break it.Declared narrowings, so review can see exactly what was and was not run here:
turbo ls --affectednames 34 packages; the 32 transitive ones are reached only throughdata-objectstack, whose exported surface delta is a pure addition (extractDraftBody) — noexisting export changed shape, and
unwrapDraftBodyis not re-exported from app-shell's publicindex. Their suites are CI's.
exit 99queue timeouts (a sibling held it 582s). It is five small test files, mutating only my ownworktree, so nothing cross-agent depended on serialising it.
lock 17m44s and the wrapper filed holder-side starvation against it. Splitting app-shell's suite would
help the queue.
Also worth the reviewer's eye
objectui#7612— switch thesharing_ruleedit-door client gate on — is filed and open, and itsrecorded blocker was the
_diagnosticsingress. #8182 closed that at the gate's own call site and thisPR closes it at the remaining nine. No new card filed; the existing one is simply unblocked. ⛔ Not
addressed here.
This PR deliberately carries no auto-close directive: objectui#8181 stays open until the PM decides
the grade, because the measurement above may move it.
Attribution, in prose because the footer block does not survive a body
PATCHintact: generated byClaude Code, session
session_01YBWFb5YgMU5dw8p2VKj16S.