Skip to content

test(app-shell): pin the permission door's draft unwrap against read decorations - #9419

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-8181-draft-envelope-read-decoration
Sep 13, 2026
Merged

os-tesla merged 1 commit into
mainfrom
claude/issue-8181-draft-envelope-read-decoration

Conversation

@os-tesla

@os-tesla os-tesla commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8181

The card's premise is falsified — read this first

The card asks for a read-decoration strip at three draft-envelope consumers. All
three already have one on main.
PR #8211 (merged 2026-09-07, f57ca757) hoisted
extractDraftBody into @object-ui/data-objectstack and wired the consumers to it.
Re-censused on fb4ec65:

site the card names strips today? by what
views/metadata-admin/PermissionMatrixEditor.tsx yes inline stripReadDecorations at its own unwrap
views/studio-design/ObjectHooksPanel.tsx yes delegates to the hoisted extractDraftBody
views/studio-design/StudioDesignSurface.tsx (x4) yes delegates, all four reads

Every getDraft call site in packages/app-shell and packages/data-objectstack
reaches a strip. So the defect the card describes is gone, and this PR does not
re-fix it.

What this PR does add, and why it is not a tidy

One of those three strips is guarded by nothing. PermissionMatrixEditor is the
only consumer that does not call the hoisted reader — it strips inline — so the
draft-envelope.test.ts pin does not cover it. Measured, three legs, all restores
proven by blob hash against HEAD:

leg mutation result
B1 remove the inline strip, keep the unwrap (the card's exact pre-fix shape) 78 passed, 0 failed — nothing guards it
B2 — lit control, same line drop the pending draft entirely 1 failed (packageDoorFacets "keeps a policy carried by the pending draft") — so the path is exercised
A — contrast remove the strip from the hoisted owner draft-envelope.ts 3 failed in draft-envelope.test.ts — so the other two sites are guarded

B2 is what makes B1 readable: the code path runs, and it was the decorations
specifically that nothing asserted. A strip that is present and unasserted is one
edit from silently coming back — which is the failure mode the card is about.

With the new pin in place, B1 now fails:

AssertionError: expected { name: 'sales_perms', ...(8) } to not have property "_diagnostics"
   176|     expect(body).not.toHaveProperty('_diagnostics');
 Test Files  1 failed | 13 passed (14)

The failure lands on line 176, after all five control assertions — so the
controls stayed lit under the mutation. They are: the re-read was attempted and
failed; the draft body really is the merge base (a key only the draft carries);
the author's edit is in the body; and _provenance / _packageId (ADR-0010
carriers, deliberately not read decorations) do ride through. That last pair is
the one that makes the absence of _diagnostics a strip rather than a blanket
underscore filter.

Why the pin drives the failure arm and not the everyday one

The success arm cannot carry decorations and proves nothing. doSave merges
through mergePermissionSlice, which starts from { ...base } and copies only
EDITOR_AUTHORED_KEYS out of the edited draft — when the fresh layered read
succeeds, base is the RAW effective layer and the merge drops decorations on
its own. A pin written there passes with the strip deleted.

The leak the card names is the other arm: client.layered(...).catch(() => null)
falls back to base = payload, the draft body itself, spread wholesale into
client.save. The card called this out as "a failure-path-only leak and it has
not been exercised"
. It is exercised here.

⛔ No schema loosened, no gate taught to tolerate _diagnostics, no second
hand-maintained key list, no production byte changed.

Gates

gate result
pnpm --filter @object-ui/app-shell type-check exit 0, 0 error TS lines (new file confirmed in the program via tsc --listFiles)
pnpm --filter @object-ui/app-shell lint exit 0, 0 errors, 2999 pre-existing warnings
eslint on the new file, --format json 1 file, 0 errors, 3 warnings (no-explicit-any, matching every sibling test)
node scripts/check-changeset-presence.mjs exit 0 — empty frontmatter, declared as releasing nothing
node scripts/check-lint-coverage.mjs exit 0 — 46/46 packages, 0 outstanding errors
node scripts/check-vi-mock-override-shape.mjs exit 0
node scripts/check-test-path-roots.mjs exit 0
turbo run build --filter=@object-ui/app-shell^... 28/28 tasks successful
vitest run packages/app-shell/src/views/metadata-admin/ packages/data-objectstack/ 324 files, 3612 passed, 1 skipped, 0 failed

Declared narrowing. The full app-shell suite was not run. This diff adds one
test file and one changeset and changes zero bytes of production source, so no
existing test can change verdict; the directory that contains the new file plus
both data-objectstack draft suites were run in full instead. Lint was narrowed on
the same basis, with its three readings stated: the checked population is
eslint.config.js's own ignore set (build output only); the count comes from
--format json; and the config enables no type-aware linting (no
parserOptions.project, no projectService), so adding a file cannot move any
untouched file's verdict. CI runs the full farm.

Acceptance notes

Filed as objectui#9420 — a second, distinct defect this PR's harness exposed
and deliberately does not fix, because it is not this card's defect class:
on that same layered().catch(() => null) arm, the package door drops other
packages' permission rows
. Measured with a one-shot probe (since deleted): with
b_order contributed by another package, the body actually PUT carried
objects: ["a_account"] and fields: []b_order and b_order.total gone,
with a 200 and no error. The merge base falls back to the draft, which the load
path already sliced to the package's own objects, so mergePermissionSlice has
nothing out-of-scope left to preserve. That is the exact guarantee the code's own
comment claims two lines above it — "so rows contributed by other packages survive
byte-for-byte (ADR-0086 P0)"
— and the same silent-discard class as #4302, which
is closed and does not cover this arm. Deduped before filing: 63 open-and-closed
package: app-shell cards, zero hits for mergePermissionSlice / ADR-0086 /
byte-for-byte row preservation, with draft-envelope as a firing control.

Noted, not filed. views/metadata-admin/clientValidation.ts (around line 553)
still states that "ResourceEditPage builds its edit draft as {...layered.effective, ...client.getDraft().item} and strips nothing", and turns the sharing_rule
edit-door gate off on that basis. Both halves are stale: ResourceEditPage strips,
and so does every other consumer. The recorded blocker is therefore cleared, which
is tracked by the already-open #7612 — no duplicate filed. Not touched here because
it is outside the three named consumers. Carrier: #7612.

Noted, not filed. ObjectHooksPanel and StudioDesignSurface have no
consumer-level decoration pin of their own; leg A shows they are covered at the
hoisted owner, which is the point of the hoist. A per-consumer pin would only add
the case "someone stops calling extractDraftBody". Left alone rather than adding
two more files to a busy package. Carrier: none.


Generated by Claude Code


Generated by Claude Code

…decorations

The permission editor is the one draft-envelope consumer that does not call
the hoisted `extractDraftBody` — it strips inline at its own unwrap. That
strip was guarded by nothing: removing it, leaving the unwrap, turned no test
red across the editor's whole suite and both data-objectstack draft suites. A
lit control at the same line (dropping the pending draft outright) did turn a
test red, so the path is exercised; only the decorations were unasserted.

The pin drives the real editor through the `layered().catch(() => null)` arm,
where `mergePermissionSlice` falls back to the draft body as its merge base
and spreads it into `client.save`. The success arm cannot carry decorations —
its base is the RAW `effective` layer — so a pin written there would pass with
the strip deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Console Performance Budget — gauge not trustworthy

The eager closure was measured, but one of the ceilings it is measured against no longer means what it names, so this run carries no pass/fail verdict for the performance budget.

This is not a budget violation. Nothing grew: the half marked below is a verdict about the gauge, and a ceiling that has stopped measuring anything can neither clear a bundle nor condemn one.

Step Outcome
Build packages success
Check console performance budget failure

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ✅ pass
Ceiling sensitivity (headroom) ⚠️ broken gauge
Ceiling freshness (checkout vs. base branch) ✅ pass

⚠️ A broken gauge half is a verdict about the ceiling, not about the bundle: that line has drifted out of range of the regression it exists to catch, or the report behind it cannot be trusted. It does not say anything grew. The Check console performance budget step log carries the ceiling and the number it was compared against.

Reason: The entry chunk measured 144.5 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.

See the workflow run for details.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 502.03KB 115.16KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 213.54KB 59.33KB
fields (index.js) 247.89KB 62.50KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.25KB 13.99KB
plugin-charts (index.js) 71.34KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.46KB 65.85KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 166.95KB 41.04KB
plugin-grid (index.js) 211.68KB 57.52KB
plugin-kanban (index.js) 46.00KB 14.30KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.64KB 6.86KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.79KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 96.00KB 31.71KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Bundle Analysis red here is the standing ui-components ceiling debt — ⛔ not this PR's

domain:ui PM seat, session_011QreXiyMEqKLN4U5daMPVa (os-tesla). Recorded per PR, as queueing with a red non-required check requires.

Every budget in the job passes; the single ❌ is the checker's gauge verdict (exit 2) that the ui-components per-chunk row's headroom has fallen under its 0.10x floor. The gate's own output says the row is "a standing debt that predates this change", that the verdict is "NOT an accusation that your diff spent the bytes", and that "there is therefore nothing here for this pull request to 'fix'" — with both greening edits (raise the ceiling, raise the allowance) ⛔ forbidden.

Taken where the gate directs: objectui#9251 (the open decision on the chunk) with objectui#9204 as the implementation row — both already exist and are held by the seat working that chunk ⇒ ⛔ no duplicate filed. This PR ⛔ edits neither ceiling nor allowance.

performance-budget.yml declares only push and pull_request — ⛔ no merge_group — and its own comment records that it "cannot hold the merge queue". Outside the 9-check required set.


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 13, 2026 18:21
@os-tesla
os-tesla added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 136c884 Sep 13, 2026
37 of 38 checks passed
@os-tesla
os-tesla deleted the claude/issue-8181-draft-envelope-read-decoration branch September 13, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Three other draft-envelope consumers take getDraft().item verbatim, with no read-decoration strip

2 participants