Skip to content

feat(data): surface save advisories from the second metadata client class (#4237) - #4258

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4237-second-client-advisories
Aug 11, 2026
Merged

feat(data): surface save advisories from the second metadata client class (#4237)#4258
yinlianghui merged 1 commit into
mainfrom
claude/issue-4237-second-client-advisories

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4237

objectui#4133 (PR #4236) put the runtime authoring gate's advisory findings in front of Studio authors — the findings that ride a 200, where the save succeeded, the row persisted and the version bumped, and the gate merely noticed something worth saying. That wiring lifts once at useMetadataClient, which is where every app-shell path takes its MetadataClient from.

ObjectStackClient.meta.saveItem — the SDK client hanging off ObjectStackAdapter — is a different class reaching the same door, and it was not covered. Every one of its callers awaited the call and discarded the response, so an advisories[] the server attached was parsed off the wire and dropped one layer further out. All of them write in active mode, so this is not the draft case where the gate never runs: the gate does run for them, produces findings, and the author was told nothing.

The seam, and which responses flow where

One emitter, installed on the adapter's own long-lived client the moment it is constructed. Every caller the card enumerates reaches the save door through that client — the four inside data-objectstack via this.client, everything outside it via getClient(), which hands back the same instance — so one interception covers all of them with no per-site edit.

MetadataService (5 saves)  ┐
useNavigationSync          │
CreateAppPage/EditAppPage  ├─ adapter.getClient() ─┐
                           │                       │   the SAME long-lived
updateViewConfig           │                       ├─  ObjectStackClient
the two view save paths    ├─ this.client ─────────┘
updateDashboard            ┘
                                     │
                                     ▼
                        client.meta.saveItem(type, name, item)
                                     │
                          PUT /api/v1/meta/:type/:name  →  200
                                     │
                          the SDK's unwrapResponse  ── returns the body VERBATIM
                                     │                  (it strips its
                                     │                   { success, data } envelope
                                     │                   only when a `data` key is
                                     │                   present; this body has none)
                                     ▼
                   ┌── the interceptor ── readSaveAdvisories(body) ──┐
   caller receives │                                                 │  non-empty?
   the response    │                                                 ▼
   UNCHANGED  ◄────┘                          adapter.emitSaveAdvisory(event)
                                                          │
                                              adapter.onSaveAdvisory subscribers
                                                          │
                                            AdapterProvider (one subscription)
                                                          │
                                              emitSaveAdvisories (the #4236 renderer)
                                                          │
                                            warning tier, title says "Saved" first

Why a sibling seam and not the onWriteWarning event itself

The card asks for the emitter shape of onWriteWarning (#3431/#3455) rather than #4236's config-callback shape, and that is what this does — a long-lived instance with a subscribe → unsubscribe registration, wired once in AdapterProvider right next to the existing one. It reuses that seam's shape, not its event type, because WriteWarningEvent is a closed shape whose required droppedFields means "fields the write legally stripped". Carrying advisories on it would either force every existing onWriteWarning subscriber to grow a branch — breaking the "existing consumers unchanged" control — or make the event lie about what happened. MetadataSaveAdvisoryEvent's own declaration already said it was "deliberately the same shape of seam as ObjectStackAdapter.onWriteWarning", so this is that sentence carried out. A test pins that a metadata save does not leak onto the write-warning channel.

emitSaveAdvisories and readSaveAdvisories are reused unchanged — one reader, two call sites.

Response shape — measured, not assumed

The premise check that mattered was whether readSaveAdvisories can even read this client's response, since the two clients could have differed in envelope. They do not, and the reason is specific:

  • SaveMetaItemResponseSchema declares advisories at the body's top level, next to success / version / seq / state, with exactly the six keys the shared reader requires.
  • The SDK's unwrapResponse strips its { success, data } envelope only when the body has a data key. The save body does not, so it comes back verbatim.

Both halves are pinned rather than trusted: the tests drive a real SDK client through a fake fetch instead of stubbing meta, and one case asserts the resolved value still carries version / seq / advisories. If a future SDK started wrapping this response, that case goes red and says so, instead of the channel quietly emitting nothing.

Draft-door honesty (D1)

Drafts are never gated — the framework returns at its D1 early return (if (args.state !== 'active') return null) before running a rule, so a draft save produces no findings to withhold. This client class has no draft door at all: the SDK's saveItem(type, name, item) takes no mode and always writes the active door, which is exactly why the gate runs for its callers. mode on the emitted event is therefore derived from the response's own state rather than from a request-side flag that does not exist here, so the event stays truthful about which door it came through instead of hard-coding one. Both halves are pinned: a draft-state response with no findings emits nothing, and a draft-state response that does advise is labelled draft.

Evidence

Reverse verification — the emitter removed, subscriptions left intact (so the failure mode is "silently discarded again", not a TypeError). Predicted before this change: red; after: green, with the negative controls staying green either way. Measured exactly that, 12 red / 7 green:

× emits the findings a successful save returned
× carries rule, message and hint through verbatim
× drops half-shaped findings rather than rendering blanks at the author
× a throwing listener never fails a save the server already committed
× unsubscribes
× labels the mode from the response state when a draft does somehow advise
× covers the adapter's own view save path (updateViewConfig)
× covers the adapter's own dashboard save path (updateDashboard)
× getClient() is the intercepted instance every external caller uses
× renders the gate findings for a save that succeeded             (MetadataService)
× lands on the WARNING tier and says "Saved" first                (MetadataService)
× covers the service's generic save door too, not just saveObject (MetadataService)

Tests  12 failed | 7 passed (19)
AssertionError: expected [] to deeply equal [ { type: 'flow', …(3) } ]

The 7 that stayed green are the honest ones: the four negative controls (clean save, empty array, no leak onto the write-warning channel, draft-state emits nothing), the SDK-envelope measurement, and the two "the save still happened" pins. None of them can observe the emitter, so none of them should have moved.

Gates (repo root, affected packages):

pnpm exec vitest run packages/data-objectstack/ packages/app-shell/
  Test Files  371 passed (371)
  Tests  3664 passed | 1 skipped (3665)

pnpm --filter @object-ui/data-objectstack --filter @object-ui/app-shell type-check
  packages/data-objectstack type-check: Done
  packages/app-shell type-check: Done      (both tsc --noEmit and tsconfig.typetests.json)

eslint (touched files)   0 errors, 119 warnings — all pre-existing no-explicit-any
check:control-bytes      OK (3968 tracked text files)
check-changeset-no-major OK

Build closure (pnpm --filter '@object-ui/app-shell^...' build) ran before type-check, so nothing was judged against a stale .d.ts.

Scope

The enumerated call sites are deliberately not edited — that they need no edit is the claim under test, and MetadataService is pinned end-to-end for exactly that reason. packages/i18n is untouched: the wording key console.saveAdvisoryTitle already ships in all ten packs from #4236, and both doors now render one wording.


Generated by Claude Code

…lass (#4237)

`ObjectStackClient.meta.saveItem` reaches the same `PUT /api/v1/meta/:type/:name`
door as `MetadataClient.save`, but #4133/#4236 only covered the latter — every
caller of the former awaited the call and discarded the response, dropping any
`advisories[]` the runtime authoring gate attached to a successful save.

`ObjectStackAdapter` gains an `onSaveAdvisory` subscription, a sibling of the
existing `onWriteWarning` seam (#3431/#3455), emitted from ONE interceptor
installed on the adapter's own long-lived client. Every enumerated caller
(MetadataService, useNavigationSync, plugin-designer's app wizard, and the
adapter's own view/dashboard save paths) reaches the door through that client,
so all of them are covered without a per-site edit. `AdapterProvider` subscribes
once and renders through the same `emitSaveAdvisories` the other client class
uses; `readSaveAdvisories` is shared unchanged — one reader, two call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 11, 2026 9:11am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.3 KB 350 KB
Entry file index-Bd5rRkXE.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 488.60KB 108.25KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 150.04KB 39.79KB
fields (index.js) 228.43KB 56.61KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.52KB 17.49KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.58KB 30.71KB
plugin-designer (index.js) 210.91KB 42.67KB
plugin-detail (index.js) 238.87KB 59.70KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 187.97KB 49.90KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.31KB 26.76KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 09:23
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 605b747 Aug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4237-second-client-advisories branch August 11, 2026 09:24
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.

A second client class also traverses the metadata save door and still discards its advisory findings

2 participants