test(app-shell): pin the permission door's draft unwrap against read decorations - #9419
Conversation
…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
|
| 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) | |
| 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. TheCheck console performance budgetstep 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
|
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) hoistedextractDraftBodyinto@object-ui/data-objectstackand wired the consumers to it.Re-censused on
fb4ec65:views/metadata-admin/PermissionMatrixEditor.tsxstripReadDecorationsat its own unwrapviews/studio-design/ObjectHooksPanel.tsxextractDraftBodyviews/studio-design/StudioDesignSurface.tsx(x4)Every
getDraftcall site inpackages/app-shellandpackages/data-objectstackreaches 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.
PermissionMatrixEditoris theonly consumer that does not call the hoisted reader — it strips inline — so the
draft-envelope.test.tspin does not cover it. Measured, three legs, all restoresproven by blob hash against HEAD:
packageDoorFacets"keeps a policy carried by the pending draft") — so the path is exerciseddraft-envelope.tsdraft-envelope.test.ts— so the other two sites are guardedB2 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:
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-0010carriers, deliberately not read decorations) do ride through. That last pair is
the one that makes the absence of
_diagnosticsa strip rather than a blanketunderscore filter.
Why the pin drives the failure arm and not the everyday one
The success arm cannot carry decorations and proves nothing.
doSavemergesthrough
mergePermissionSlice, which starts from{ ...base }and copies onlyEDITOR_AUTHORED_KEYSout of the edited draft — when the freshlayeredreadsucceeds,
baseis the RAWeffectivelayer and the merge drops decorations onits 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 intoclient.save. The card called this out as "a failure-path-only leak and it hasnot been exercised". It is exercised here.
⛔ No schema loosened, no gate taught to tolerate
_diagnostics, no secondhand-maintained key list, no production byte changed.
Gates
pnpm --filter @object-ui/app-shell type-checkerror TSlines (new file confirmed in the program viatsc --listFiles)pnpm --filter @object-ui/app-shell linteslinton the new file,--format jsonno-explicit-any, matching every sibling test)node scripts/check-changeset-presence.mjsnode scripts/check-lint-coverage.mjsnode scripts/check-vi-mock-override-shape.mjsnode scripts/check-test-path-roots.mjsturbo run build --filter=@object-ui/app-shell^...vitest run packages/app-shell/src/views/metadata-admin/ packages/data-objectstack/Declared narrowing. The full
app-shellsuite was not run. This diff adds onetest 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-objectstackdraft suites were run in full instead. Lint was narrowed onthe 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 (noparserOptions.project, noprojectService), so adding a file cannot move anyuntouched 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 otherpackages' permission rows. Measured with a one-shot probe (since deleted): with
b_ordercontributed by another package, the body actually PUT carriedobjects: ["a_account"]andfields: []—b_orderandb_order.totalgone,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
mergePermissionSlicehasnothing 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-shellcards, zero hits formergePermissionSlice/ADR-0086/byte-for-byte row preservation, with
draft-envelopeas a firing control.Noted, not filed.
views/metadata-admin/clientValidation.ts(around line 553)still states that "
ResourceEditPagebuilds its edit draft as{...layered.effective, ...client.getDraft().item}and strips nothing", and turns thesharing_ruleedit-door gate off on that basis. Both halves are stale:
ResourceEditPagestrips,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.
ObjectHooksPanelandStudioDesignSurfacehave noconsumer-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 addingtwo more files to a busy package. Carrier: none.
Generated by Claude Code
Generated by Claude Code