fix(app-shell): route three more publish-drafts call sites through the advisory reporting seam (objectui#10039) - #10147
Conversation
…e advisory reporting seam
Three app-shell call sites still fired `POST /packages/:id/publish-drafts`
with a bare `fetch`, outside the seam that can report — the AI build
pane's standing unpublished-changes bar, the Studio workbench's
package-level publish, and the chat transcript's draft-card publish. The
server has answered per-draft `advisories` on each `published[]` element
since objectstack#9343, and objectui#6965 / PR objectui#10038 built the
client half for the two call sites that card named. On these three the
findings were parsed by nobody.
Each now takes its client from `useMetadataClient` and calls
`MetadataClient.publishPackageDrafts`. The advisory toast belongs to the
client, so all three present identically to the two already routed — one
warning per advised item, naming that item's own `type` / `name`, through
the sink, event, renderer and wording every other write door uses. No new
presentation was invented and none was dropped.
What moves with the route:
- A non-2xx raises `MetadataError` inside the client rather than being
read off `res.ok`. The message is still the server's own, and the
ADR-0112 producer-marked `error.userMessage` now outranks the diagnostic
where a refusal carries one. `StudioDesignSurface` keeps its
field-anchored issue rendering: `parseError` already puts
`error.details.issues` on `MetadataError.issues`, which is what its
`formatMetadataError` reads.
- `failed[]` / `failedCount` / `seedApplied` read through ONE spelling.
The client unwraps the dispatcher's `{ success, data }` for this route —
the one route whose spec declaration says the body arrives inside one —
so the two server compositions are reconciled before a caller sees them,
where each bare-fetch site carried its own `payload?.data?.x ?? payload?.x`
ladder.
- The 2xx batch verdict stays each surface's own. `success: false` is not a
refusal on this route and the three disagree on purpose about what a
partial or rolled-back batch should say.
Pins: one per surface, each driving that surface's own publish affordance
against the batch body the gate actually sends and asserting the finding's
prose reaches the sink — with a clean-publish control in the same file, so
the silence is a reading about an empty advisory list rather than about a
chain that never ran. Each deliberately does NOT mock `useMetadataClient`:
mocking that hook is exactly how this class of hole stays invisible.
`PendingDraftsBar.test.tsx`'s fetch stub now answers with real `Response`
objects. The console's authenticated fetch reads `response.headers` on
every `/api/` call, which a `{ ok, status, json }` literal does not have.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
|
changeset-claim-re-read
|
|
| 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 146.9 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.68KB | 6.20KB |
| 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) | 545.92KB | 130.72KB |
| core (index.js) | 8.94KB | 3.59KB |
| create-plugin (index.js) | 27.94KB | 9.51KB |
| data-objectstack (index.js) | 221.99KB | 61.72KB |
| fields (index.js) | 251.81KB | 63.47KB |
| 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.90KB | 10.97KB |
| 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) | 5.52KB | 2.10KB |
| 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) | 51.08KB | 14.50KB |
| plugin-charts (index.js) | 71.73KB | 20.08KB |
| plugin-chatbot (index.js) | 198.20KB | 47.14KB |
| plugin-dashboard (index.js) | 132.96KB | 35.17KB |
| plugin-designer (index.js) | 215.94KB | 44.33KB |
| plugin-detail (index.js) | 255.17KB | 66.48KB |
| plugin-editor (index.js) | 2.23KB | 1.05KB |
| plugin-form (index.js) | 139.56KB | 35.40KB |
| plugin-gantt (index.js) | 167.99KB | 41.37KB |
| plugin-grid (index.js) | 213.40KB | 58.19KB |
| plugin-kanban (index.js) | 48.71KB | 15.17KB |
| plugin-list (index.js) | 113.53KB | 27.99KB |
| plugin-map (index.js) | 21.48KB | 6.99KB |
| plugin-markdown (index.js) | 13.88KB | 4.80KB |
| plugin-report (index.js) | 43.49KB | 11.97KB |
| plugin-timeline (index.js) | 30.68KB | 8.95KB |
| plugin-tree (index.js) | 10.58KB | 3.72KB |
| plugin-view (index.js) | 85.18KB | 21.05KB |
| 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) | 109.04KB | 36.08KB |
| 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 (body-dialect.js) | 4.38KB | 1.98KB |
| sdui-parser (codegen.js) | 6.58KB | 2.74KB |
| sdui-parser (dashboard-widget-options.js) | 3.08KB | 1.30KB |
| sdui-parser (index.js) | 5.74KB | 2.54KB |
| 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) | 15.71KB | 5.30KB |
| types (ai.js) | 4.11KB | 2.06KB |
| 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) | 5.00KB | 2.39KB |
| 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
✅ 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
|
Fixes #10039
Clause-②: no — three call sites move onto an existing client method. No exported symbol and no key on a published payload moves.
Three app-shell call sites still fired
POST /packages/:id/publish-draftswith a barefetch, outside the seam that can report. The server has answered per-draftadvisorieson eachpublished[]element since objectstack#9343 landed, and objectui#6965 / PR #10038 built the client half for the two call sites that card named. On these three the findings were parsed by nobody.Session reference, in prose because a footer does not reliably survive an edit:
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRqThe enumeration, re-taken rather than inherited — and it does NOT reproduce the card's control
Every figure below is a READING at the sha it names, taken with the instrument spelled beside it. None of them is a live count and nothing re-derives them after this paragraph is written; re-run the instrument rather than trusting the number.
Instrument (uncapped, counted before any row was read — no
head -Nanywhere in it):030a675b0030a675b0origin/mainf760064eaSo the card's file count reproduces exactly and its non-test row count does not — not at today's head, and not at the sha the card itself names. Three narrower spellings were tried against the card's own pair and none produces 9 rows over 11 files (
/publish-draftsgives 8 rows over 7 files; the backtick-terminated spelling gives 7 over 7). The finding the card reports is unaffected — the three call sites it names were all still barefetchcalls on today'sorigin/main— but its control figure is not a number this branch could re-derive, and it is reported rather than repeated.Why enumerations of this route keep coming up short, measured rather than guessed. A sweep for
await fetch(on the same line as the route sees 2 of the 3, becauseAiChatPagewraps its call arguments and the URL sits on its own line. A multiline-aware reader — one that allows whitespace and a newline betweenfetch(and the/api/v1/packages/URL — answers 1 per file for all three. The route-name sweep in the table above sees all three, at the cost of also matching eleven comments.On today's
origin/main, three bare-fetchcall sites of this route existed and this branch routes all three. After the change the same multiline-aware reader finds none inpackages/app-shell/src+apps/console/src, beside a control on the same tree: 13 files still mention the route (in prose, in tests and at the five routed call sites).The repair
Each of the three takes its client from
useMetadataClientand callsMetadataClient.publishPackageDrafts, exactly as PR #10038 did forpreview/usePublishAllDrafts.tsandviews/metadata-admin/PackagesPage.tsx.useMetadataClientis the layer that hands the console's advisory toast renderer to the client factory, so taking the client from there is the whole mechanism — nothing new was invented.Carried along with the route, at all three:
MetadataErrorinside the client instead of being read offres.ok. The message is still the server's own, and the ADR-0112 producer-markederror.userMessagenow outranks the diagnosticerror.messagewhere a refusal carries one — the rule objectui#7959 landed onPackagesPage, reaching these surfaces through the same seam rather than as a fourth copy.StudioDesignSurfacekeeps its field-anchored issue rendering:parseErroralready putserror.details.issuesonMetadataError.issues, which is what itsformatMetadataErrorreads.failed[],failedCountandseedAppliedare read through ONE spelling. The client unwraps the dispatcher's{ success, data }for this route — the one route whose spec declaration says the body arrives inside one — so the two server compositions are reconciled before a caller sees them, where each bare-fetchsite carried its ownpayload?.data?.x ?? payload?.xladder.success: falseis not a refusal on this route and the three disagree on purpose about what a partial or rolled-back batch should say. Where a 2xx refusal used to end inHTTP ${res.status}, the status is no longer in hand, so the last rung is a sentence —PackagesPage's own key for this route on the Studio surface, the existingconsole.ai.publishFailedon the chat one.How the advisories are presented, one line per surface
They present identically on all three, and that is not a compromise — the presentation belongs to the client, not to the surface.
publishPackageDraftsemits oneMetadataSaveAdvisoryEventper advisedpublished[]element into the sinkuseMetadataClientwires, andemitSaveAdvisoriesrenders it as one warning toast titled "Published — the authoring check raised N advisory finding(s)" with the findings as its description. So:console/ai/PendingDraftsBar(the AI build pane's standing unpublished-changes bar) — the advisory toast, beside the bar's own existing probe/seed toast. Both now fire from one publish; neither replaces the other.views/studio-design/StudioDesignSurface(the Studio workbench's package-level publish) — the advisory toast, beside this surface's ownpublishedAllsuccess toast or itsformatPublishFailureserror toast.console/ai/AiChatPage(the chat transcript's draft-card publish) — the advisory toast. This surface additionally hands an ADR-0038 L3healthobject back to the chat card so the Published card grows a build-health line; that hand-off is unchanged and is a different channel from the advisories, which have no chat-card representation and did not need one.No surface was found that cannot present them, so nothing was dropped and no blocker is reported on that count.
Evidence
RED before / GREEN after, with the file state verified in the same command as each run. Each pin was written and run first with all three implementation files byte-identical to
origin/main3ac2de829— proved by printinggit hash-object FILEbesidegit rev-parse HEAD:FILEfor each, in the same shell command as the run, not by an exit code. All three failed onexpect(toast.warning).toHaveBeenCalled(): no advisory toast at all. After the repair all three pass.Each pin asserts the finding REACHES the author, not that a function was called. The fixture is one finding in the gate's D3 shape with all six required keys —
readSaveAdvisoriesdrops a half-shaped one, so a fixture missing a key would make the file pass or fail for the wrong reason. The assertions read the toast's title (it must start with "Published", which proves the door discriminator survived the whole seam) and its description, which must contain the finding's ownmessage,ruleandhint.Each pin deliberately does NOT mock
useMetadataClient. That is the point:useMetadataClient.advisorySink.test.tsxrecords that 51 suites mock that module and, before it existed, exactly zero imported the real hook — the seam was mocked away everywhere it appeared, which is how this class of hole stays invisible. Real here: the component, the hook, itsuseCallbacksink,createConsoleMetadataClient, the authenticated fetch,MetadataClient, itspublished[]walk,readSaveAdvisoriesandemitSaveAdvisories. Stubbed:sonner(a module binding, so it cannot be handed over),globalThis.fetch, and — per surface — the consumer that merely calls the handler under test.Every zero carries a same-subject control in the same command. Each pin has a CONTROL case: the same drive against a clean batch body (the server omits
advisories), asserting the publish really travelled the chain (the POST count, and the surface's own success toast or its verdict to the chat card) while the advisory sink stays silent. So "nothing was toasted" is a reading about an empty advisory list, not about a chain that never ran.Gates re-run as the last action before the push, with the tree proved equal to the pushed commit —
git write-treeprinted equal toHEAD^{tree}andgit status --porcelainempty, in the same command as the gate runs. Green and each one SELECTING these files rather than collapsing:check-changeset-presence— exit 0, and it names the selection: "4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)". The changeset decision was made by running this gate, not from memory.check-changeset-no-major,check-changeset-overwrite— exit 0.check:control-bytes— exit 0 over 8120 tracked text files. Plus a direct scan of the eight touched paths: 0 control bytes, beside a control of 8033 printable-character hits in the same files.check:i18n-keys— exit 0; it reports 2756 interpolation call sites and 3005 en keys judged, so it saw the twot()calls this change adds or moves.check:vi-mock-specifiers/-inherit/-override-shape— exit 0 over 3372 test-named files; the three new suites are inside that population.check:test-path-roots,check:metadata-write-doors,check:unreferenced-sources,check:phantom-deps,check:unused-deps,check:i18n-drift,check:i18n-dead-keys— exit 0.check:new-line-citations— 0 new cross-file line citations; report-only, so this is a reading and not a verdict.check:changeset-claimsis also report-only, and its own output says two of its three sub-readings measured nothing on this event; its one actionable finding is answered below.Tests.
@object-ui/app-shellin full, in two shards: 371 files / 3545 tests + 1 skipped, and 370 files / 3782 tests — both exit 0 with no failure. The two directories this change touches, run together: 85 files / 488 tests, exit 0.pnpm --filter @object-ui/app-shell run type-checkexit 0, and the script it echoed istsc --noEmit && tsc -p tsconfig.test.json, so the new test files are type-checked too. That run neededpnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' buildfirst; a first attempt passed--concurrency=2after the filter, which pnpm forwards verbatim intotsup— that run is recorded as not-a-verdict and was re-run properly.Lint.
pnpm --filter @object-ui/app-shell run lintexit 0 (3048 pre-existing warnings, 0 errors). Narrower reading, to show this change adds none: eslint over exactly the 8 touched paths reports 0 errors and 49 warnings, and 0 of those 49 fall inside a hunk this branch adds (computed against the new-side line ranges ofgit diff -U0), beside the control that all 49 fall outside them. The narrowing is safe because this repo'seslint.config.jsdeclares no type-aware linting: 0 hits forprojectServiceorproject:, beside a control of 13 hits forrulesin the same file — so no untouched file's verdict can move with this diff.The pending changesets
check:changeset-claimsasked this seat to re-read, re-read:.changeset/6169-chatbot-authoring-face-type.mdnamesAiChatPage.tsxas the live consumer of theautoResponsedisplay fields. This change touches neither those fields nor that consumer relationship. Still true..changeset/6681-declared-lazy-marketplace-routes.mdnamesStudioDesignSurface.tsxas the eager static importer ofcomponents/SuggestedBindingsPanel.tsx, and quotes an eager-closure byte delta. That import is untouched, and the byte figures are that change's own measured delta rather than a live total. Still true.The eager closure, stated rather than measured here.
check:eager-closureneeds a built console dist and is CI's run, not this branch's. What is cheap and was checked: both modules newly imported by these files are ALREADY in the eager closure —views/metadata-admin/useMetadata.jsthroughStudioDesignSurface's own existing import of it, andutils/apiErrorEnvelope.jsthroughpackages/app-shell/src/index.ts, which re-exportsreadEnvelopeFailureTextfrom the barrel. So this change adds edges between already-eager modules and puts no new module into the closure. Chunk assignment is still CI's to weigh.One test fixture had to grow, and the reason is the repair.
PendingDraftsBar.test.tsxanswered its fetch stub with{ ok, status, json }object literals. The console's authenticated fetch readsresponse.headerson every/api/call (theset-auth-tokensession rotation), and a literal has noheaders, so it threw inside the wrapper, the bar reported a failed publish and skipped the bus pulse — leaving that file's "then hides" pin red for a reason entirely its fixture's. The stub now answers with realResponseobjects; the assertions are unchanged.Noted, outside the declared file surface
The triage seat asked how a FOURTH call site would be prevented, and that is the right question — two rounds, two counts. The repo already owns the answer's SHAPE, one route over:
scripts/check-object-metadata-write-doors.mjsenumerates DOORS rather than writers, on the stated ground that "a ruling that enumerates writers is only as good as the enumeration", and it was built after exactly this pattern (objectui#7714 named two writers, objectui#8057 hit a third, objectui#8676's sweep found nine more). A sibling door census forPOST /packages/:id/publish-drafts— every in-repo call of that route must reachMetadataClient.publishPackageDrafts— would close this class by construction. It lives inscripts/, outside this card's declared file surface, so it is reported here for the seat that routes rather than taken.Generated by Claude Code