Skip to content

fix(data-objectstack): MetadataClient.get() honors its unwrapped-body contract — the field permission matrix lives (#4271) - #4545

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4271-metadata-get-unwrap
Aug 13, 2026
Merged

fix(data-objectstack): MetadataClient.get() honors its unwrapped-body contract — the field permission matrix lives (#4271)#4545
yinlianghui merged 1 commit into
mainfrom
claude/issue-4271-metadata-get-unwrap

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4271

The disagreement

GET /api/v1/meta/:type/:name answers the spec-declared envelope { type, name, item, …protection fields }one shape, for published and draft reads alike, since objectstack#5563 collapsed the read to it. MetadataClient.get() handed that envelope straight back while its own docblock declared it returned "the unwrapped item content". Every consumer reading obj.fields therefore read undefined.

Verified on current origin/main before implementing — premise holds, at both ends:

  • Client: metadata-client.ts returned (await res.json()) as T with no unwrapping, under a docblock promising the body.
  • Server: packages/rest/src/index.ts in the framework records the other half — "GET /meta/:type/:name answers exactly one body shape now — the spec-declared { type, name, item, … } envelope … reading .item is unconditionally correct." GetMetaItemResponseSchema declares type: string, name: string, item: unknown plus the ADR-0008 protection carriers.

Census — the ruling's first step

Every get() call site, classified. Zero deliberate envelope readers; nine consumers reading the body directly, all broken identically:

Call site Reads Class
PermissionMatrixEditor.ensureFields obj?.fields b — broken (card headline)
PermissionMatrixEditor.loadObjectFields obj?.fields b — broken (card collateral)
previews/useObjectFields obj.fields b — broken
previews/useDatasetCatalog toCatalogEntry(doc) b — broken, silently partial
inspectors/useDatasetFields ×2 normalizeObject(doc) b — broken
ReportView def?.object / def?.dimensions b — drill fallback inert
metadata-admin/anchors buildDefaultPageSchema(objectDef) b — broken
ResourceEditPage:615 obj.fields b — broken
plugin-designer/MetadataFieldsPage raw.fields, then saves { …raw, fields } back b — broken and corrupting
runtime-metadata-persistence.readRuntimeDraft unwrapDraftBody c — tolerant, pinned both ways
data-objectstack updateView unwrapViewDraft c — tolerant

The useDatasetCatalog case is the nastiest: an envelope carries a real name, so it produced an entry that looked valid and had zero dimensions and measures.

No class-(a) compensator exists, which is what makes the contract fix clean — every broken site is repaired without being edited. That includes ResourceEditPage.tsx, which #4306 owns: the mutual exclusion was checked and is not triggered, because nothing there needed touching.

Why nothing caught this: the repo's test doubles were written against the docblock. They answer a bare { fields } body, so the suite exercised the documented contract while production ran the other one.

The fix

At the producer, once, at the client boundary. Detection is by presence of the three keys the spec declares — never guessed from payload contents:

  • a metadata document may carry its own type and name (a view is { name, type: 'grid', … }); with no item key it is the body and is left whole;
  • a document with an item property of its own but no envelope identity is likewise untouched;
  • key count is deliberately not part of the test, since a real envelope also spreads the protection carriers.

Non-envelope responses pass through byte-for-byte and 404 still reads as null.

getDraft() is unchanged and keeps returning the envelope — its docblock declares it and roughly eleven call sites read .item off it (StudioDesignSurface, ResourceEditPage, PackageOwdOverviewPanel, ObjectHooksPanel, PermissionMatrixEditor). The asymmetry is now real rather than aspirational: both methods share one private transport and differ only in whether they unwrap. Routing getDraft through the unwrapping get() would have silently emptied all eleven — and would have required editing two files #4306 owns.

Red-first, with the direction predicted in writing

Predicted before running, then run against unfixed code. 8 red / 7 green, exactly as predicted except one noted below.

× A1: unwraps `{type,name,item}` to the item body
  AssertionError: expected { type: 'object', …(2) } to deeply equal { name: 'showcase_project', …(2) }
× A3: an envelope carrying a null item reads as null
  AssertionError: expected { type: 'object', name: 'ghost', …(1) } to be null
× B1: lists the object's fields with a readable/editable pair each
  TestingLibraryElementError: Unable to find a label with the text of: showcase_project.email readable
× B2: loadObjectFields feeds the RLS CEL editor real field names
  AssertionError: expected [] to deeply equal [ 'amount', 'budget', …(19) ]

Green after the fix: 42/42. Reverse-verified by removing the fix via patch file and git checkout (never git stash) — the same 8 return red — then restored with a sha256-verified byte-identical restore.

A10getDraft() returns the raw envelope — is green before and after, deliberately. It is the guard on the design: it goes red the moment getDraft is left delegating to the unwrapping get().

One prediction was wrong, reported rather than papered over. I predicted A12 (URL construction) green-before; it went red, because I had folded an unwrap assertion into that case alongside the URL assertions. The URL half was green throughout — only the bundled unwrap assertion moved. A second authoring error was mine too: B1b initially asserted readable starts unchecked, when the real default posture is readable-but-not-editable; the case now toggles editable, which is the half with somewhere to travel.

The app-shell test drives a real MetadataClient over a fetch answering the real server shape — nothing in it mocks get(). That is the gap the existing doubles left open.

Verification

Gate Result
data-objectstack full suite 470/470 — find() / probeAppAccess (#4495/#4521) unmoved
metadata-admin + studio-design + runtime persistence 1690 passed, 1 skipped
Full app-shell + plugin-designer 3628 passed, 1 skipped, 376 files
Permission family artifact-tier, readonly, badge, fieldBulk, scope, dirty, embedded, permission-slice, CEL family — all green
Both tsc passes, both packages clean (app-shell runs --noEmit and tsconfig.test.json)
Downstream consumer sweep 30 packages green — prefix direction (...@object-ui/data-objectstack, i.e. consumers)
ESLint vs origin/main baseline 0 errors; warning counts identical to baseline (114 / 11 / 0)
check-control-bytes, check:phantom-deps, changeset:check all green

One downstream package first failed on a missing @object-ui/app-shell declaration — a missing-artifact effect of building only the dependency closure, not the packages themselves. It is green after building both; not a type error from this change.

.d.ts diff, measured both ways

Clean rebuild with dist/ and tsconfig.tsbuildinfo cleared between builds.

  • data-objectstack — documentation plus one added private readItemResponse;. Public signatures unchanged: get and getDraft keep (type, name, options?) = Promise< T | null >.
  • app-shellzero diff across all 415 declaration files.

Changesets

@object-ui/data-objectstack minor — published behavior moves for existing callers, the #4495 find() resolve-to-reject grading. No signature changed, so nothing needs a code edit to keep compiling.

Deviation, declared: the ruling anticipated an app-shell patch changeset "per its own .d.ts diff". Measured, that diff is empty and app-shell has no source change at all — only test additions — so no app-shell changeset was written. It is in the fixed group with data-objectstack and releases alongside it regardless.

Notes

  • loadObjectFields lives in PermissionMatrixEditor.tsx, not in a separate celAuthoring module as the dispatch brief guessed. celAuthoring.ts exists but only consumes the field list. A location refinement, not a premise inversion.
  • Neither named consumer was edited. That is the point of fixing the contract side — consumer-side unwrapping is the lenient-consumer patch this repo rejects.
  • The now-stale comment in data-objectstack/src/index.ts claiming "a published read is the bare body" is corrected, since it would have misled the next reader in exactly the way this defect was born.

Generated by Claude Code

… contract (#4271)

`GET /meta/:type/:name` answers the spec-declared envelope
`{ type, name, item, ...protection fields }` — one shape for published and
draft reads alike since objectstack#5563. `get()` handed that envelope back
while its docblock declared it returned the unwrapped item content, so every
consumer reading `obj.fields` read `undefined`.

A census of all `get()` call sites found ZERO deliberate envelope readers and
nine consumers reading the body directly, all broken identically: the field
half of the permission matrix ("No fields registered for this object." for
every object), RLS CEL field lint/autocomplete, the dataset inspectors and
preview hooks, the report drill-down fallback, the record-page seed, and the
Field Designer — which also wrote the envelope back over the object body.
Nothing caught it because the repo's test doubles were written against the
docblock, answering a bare `{ fields }` body.

Fixed at the producer: `get()` unwraps once at the client boundary, so all
nine call sites are repaired without being touched. Detection is by presence
of the three keys `GetMetaItemResponseSchema` declares, never guessed from
payload contents; non-envelope responses pass through unchanged and 404 still
reads as null. `getDraft()` keeps returning the envelope its docblock declares
and ~11 `.item` readers depend on — the two now share one private transport
and differ only in whether they unwrap.

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

vercel Bot commented Aug 13, 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 13, 2026 7:20am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-OY-uZbZl.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
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) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
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) 5.02KB 0.88KB
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) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 158.47KB 43.13KB
fields (index.js) 230.14KB 57.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
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.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
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) 189.28KB 50.29KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 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.73KB 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

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)

  • The census did exactly what the ruling built it for, and its verdict was decisive: ZERO deliberate envelope readers on get(), so the contract side was unambiguous and all nine broken consumers are repaired WITHOUT edits — which is also why the Field inspector: Save is not gated on CEL errors — a parse-fault formula saves and publishes as the live field definition #4306 mutual-exclusion never fired (ResourceEditPage:615 is class (b), healed through the contract). getDraft() preserved with its ~11 .item readers, both methods sharing one transport so the documented asymmetry is structural, and the envelope detected by PRESENCE of the three spec-declared keys, not key-count.
  • The census also surfaced the hidden severity: plugin-designer's Field Designer save was writing {...envelope, fields} back as the object BODY — a data-corruption path, not an empty render. That is the strongest single argument for fixing the producer, and it is now correct via the repaired contract. The offered follow-up (a dedicated regression pin on that save path) is accepted and will be filed as a pooled card.
  • The root-cause note deserves permanence: every existing test double mocked get() against the DOCBLOCK, so 3,628 passing tests exercised the documented contract while production ran the other one. The new suite drives a REAL MetadataClient over a real-server-shaped fetch with nothing mocking get() — that pattern is the durable guard, and future client-contract cards should copy it.
  • Red-first 8/7 with the one prediction miss owned as an authoring error (A12's bundled assertion), reverse verification exact with sha256 restores, and A10 standing guard: getDraft's raw-envelope pin reds the moment anyone makes it delegate to the unwrapping get().
  • Grading right: data-objectstack MINOR on the fix(list): OBJECT_API_DISABLED renders an honest cannot-work state instead of the empty state (#4408) #4495 behavior-move precedent with public signatures byte-identical; NO app-shell changeset because its .d.ts diff is EMPTY and there is no source change — the declared deviation from the ruling's expectation is correct, a changeset there would misdescribe the release.
  • 30 downstream consumers type-check green; the eight would-be duplicate findings correctly NOT filed; CI 20/20 on per-job conclusions.

Auto-merge armed (squash) — landing verified per the merge-queue discipline.


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 07:32
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 479cc7b Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4271-metadata-get-unwrap branch August 13, 2026 07:32
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.

permission-matrix-edit-loop: the field-level half of the permission matrix is dead for every object (MetadataClient.get() does not unwrap the envelope)

2 participants