Skip to content

fix(metadata-admin): strip read decorations before the client edit gate - #8182

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-7603-diagnostics-pending-draft-merge
Sep 6, 2026
Merged

fix(metadata-admin): strip read decorations before the client edit gate#8182
os-justin merged 1 commit into
mainfrom
claude/issue-7603-diagnostics-pending-draft-merge

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #7603

What was wrong

client.getDraft() serves a decorated body: the strict draft branch returns
item: decorateMetadataItem(type, ...), which attaches _diagnostics whenever the type has a
registered Zod schema, and _draft rides the preview-draft branch. ResourceEditPage merged
that body over the layered baseline ({ ...baseline, ...draftReal }) and handed the result
straight to validateMetadataDraft.

The layered half is clean — getMetaItemLayered serves RAW layers — so the misfire needed one
condition: the item has a pending draft. That is why it stayed invisible; an item without a
draft passes today and cannot catch it.

Result: every wired metadata type whose schema is .strict() reported a body the server
accepts
as unrecognized_keys — a false "this item is invalid" banner plus inline field
errors, on exactly the workflow where an author is mid-edit.

The fix

extractDraftBody now passes the body through the spec's exported stripReadDecorations.

That function is the chokepoint: it is the one place a served draft envelope becomes a
body, and all three merge sites read it — the load effect (:950), the post-save refresh
(:1440) and the post-publish refresh (:1592). The card names only the first; fixing the
chokepoint closes all three and leaves no fourth site to forget.

The strip runs after the presence verdict, never before it: what counts as a pending draft
is getDraft's answer, and removing our own decorations must not be able to turn a served
draft into "no draft".

⛔ No schema was loosened, and no key list is restated in this repo. The list is the spec's,
reached through its own helper. The ADR-0010 protection envelope (_lock, _provenance, …) is
deliberately not on that list — those keys are allowlisted by the closed schemas so
provenance survives a re-parse — and this strip leaves them alone.

Corrections to the dispatch's premises (both measured)

  1. Every anchor in the card and triage landed exactly. Re-measured on origin/main
    9bfd61848: MetadataService.ts 617 / 658 / 665 ✅; ResourceEditPage.tsx 939 / 949 / 950 /
    951 / 429 ✅. Nothing had moved.
  2. ⚠️ The existing strip is a WRITE-path strip, not a read-time one. Triage and the dispatch
    both describe MetadataService.ts:658-665 as a read-time strip. Measured: it sits inside
    saveFields and strips on the way out to the server, and its own comment says so
    ("the objectui#4644 strip-on-load shape applied on the write side"). Before this PR there
    was exactly one stripReadDecorations call in the whole repo — MetadataService.ts:679.
    The adjudication is not void: the reusable asset is the spec's exported helper, and that
    is what this PR reuses. Only the direction was described wrongly.

Evidence

The bug, proven on the pre-change tree. ResourceEditPage.tsx was reverted to its
origin/main bytes (blob 75680b79, verified by git hash-object, and the
stripReadDecorations anchor count went 2 → 0 on disk before the run). Both pins go RED, for
exactly the stated reason:

AssertionError: expected [ 'name', 'label', 'type', …(4) ] to not include '_diagnostics'
AssertionError: expected 'Unrecognized key(s) on this page: `_d…' not to contain '_diagnostics'
 Test Files  1 failed (1)
      Tests  2 failed (2)

Restore proven by state, not by an exit code. git hash-object returned d5c59d3a,
equal to git rev-parse HEAD:PATH for the same file, with git diff HEAD empty and the anchor
count back to 2.

The fixed tree. Test Files 1 passed (1) · Tests 2 passed (2). Re-run after the final
commit alongside the two suites nearest this behaviour (ResourceEditPage.schemaAdvisory,
clientValidation.optOuts): Test Files 3 passed (3) · Tests 25 passed (25).

Schema-level measurement (bundled spec, PageSchema):

body verdict
clean ACCEPT
+ _diagnostics REJECT unrecognized_keys: ["_diagnostics"]
+ _draft REJECT unrecognized_keys: ["_draft"]
+ both REJECT unrecognized_keys: ["_diagnostics","_draft"]
+ zzBogus REJECT unrecognized_keys: ["zzBogus"]
stripReadDecorations(+ both) ACCEPT
stripReadDecorations(+ zzBogus) REJECT ["zzBogus"] — the strip does not over-reach

Tests

Two directions, as required — and the second is also the first's control, so "no issues" is
a measured verdict rather than a validator that never fired:

  • a served body whose pending draft carries _diagnostics and _draft now passes the gate;
  • a genuinely undeclared key riding that same decorated draft is still refused, by name,
    with neither decoration named alongside it, and the banner fires.

The pins assert the gate's own verdict (via a recording wrapper around the real
validateMetadataDraft), not merely a rendered banner.

All readings below were taken on the final commit d16f2845c.

Gates run locally

gate result
dependency-closure build (--filter '@object-ui/app-shell^...' build) exit 0
@object-ui/app-shell type-check exit 0
new pin 2 passed
whole metadata-admin suite 231 files / 2385 passed, 1 skipped, exit 0
check:control-bytes · check:vi-mock-specifiers · check:vi-mock-inherit
check:phantom-deps · check:spec-symbols
check-changeset-presence · check-changeset-no-major
check-governed-queue-guard --test ✅ NOT GOVERNED (control: AGENTS.md → governed)

Lint — a declared narrowing, measured. Repo-wide turbo run lint is CI's run. Locally the
two changed source files were linted with the package's own command (eslint ., no extra
flags): exit 0, 0 errors, 2 files linted per --format json. The 55 warnings in
ResourceEditPage.tsx are pre-existing — the first is at line 322 and my changed ranges are
130 and 219-262, so 0 warnings fall inside them. The narrowing excludes nothing:
eslint.config.js enables no type-aware linting (no projectService, parserOptions.project
or *TypeChecked config anywhere in it — zero hits, against a control of 10 hits for rules),
so this diff cannot move the verdict on any file it does not touch.

Contract review

needs:contract-review is attached. The diff changes accept/reject behaviour on a contract
gate
: the client edit gate now accepts bodies it previously rejected. It does so by removing
the framework's own read-time decorations before a re-parse — which is what the spec prescribes
— and not by loosening any schema, so server parity moves toward the server rather than away
from it. No published face is widened: extractDraftBody is module-local, and this PR adds no
exported symbol at all (one import, one call).

Scope

Three files: the fix, its pin, one changeset. ⛔ The hot files held by #8164
(ConditionalFormattingEditor.tsx / .test.tsx) are untouched — 0 hits in the changed-file
list. Out-of-scope finding filed unassigned as #8181 (three other draft-envelope consumers
take getDraft().item verbatim with no strip); ⛔ not folded in here.

sharing_rule was not re-gated — its immunity is an accident of AUTHOR_SHAPE_ONLY_TYPES
that #6982 preserves deliberately for this measurement.


Generated by Claude Code

`client.getDraft()` serves a decorated body — `decorateMetadataItem` attaches
`_diagnostics` whenever the type has a registered Zod schema, and `_draft`
rides the preview-draft branch. `ResourceEditPage` merged that body over the
layered baseline and handed the result to `validateMetadataDraft`, so every
wired type with a `.strict()` schema reported a body the SERVER ACCEPTS as
`unrecognized_keys` — a false "this item is invalid" banner plus inline field
errors on an author who is mid-edit.

The layered half is clean (`getMetaItemLayered` serves RAW layers), so the
misfire needed a pending draft to exist; an item without one passes today,
which is why this stayed invisible.

Strip in `extractDraftBody` — the one function that turns a served draft
envelope into a body, and the chokepoint all three merge sites read (the load
effect, the post-save refresh, the post-publish refresh). It reuses the spec's
exported `stripReadDecorations`, the same helper `MetadataService.saveFields`
uses on the write side; no key list is restated here, and no schema is
loosened. The strip runs after the presence verdict so it can never turn a
served draft into "no draft".

Refs objectui#7603 (the closing declaration lives in the PR body, once).

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

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3189.8 KB 3191.4 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-Dl6XMh2W.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
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) 5.13KB 2.35KB
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) 498.09KB 113.98KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 187.85KB 52.13KB
fields (index.js) 243.04KB 61.36KB
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) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
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.84KB 10.94KB
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) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 47.29KB 13.18KB
plugin-charts (index.js) 70.62KB 19.71KB
plugin-chatbot (index.js) 193.54KB 46.04KB
plugin-dashboard (index.js) 131.41KB 34.43KB
plugin-designer (index.js) 211.51KB 43.01KB
plugin-detail (index.js) 247.75KB 63.50KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 52.46KB 14.57KB
plugin-list (index.js) 113.34KB 27.72KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.48KB 20.81KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
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.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
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.74KB 1.41KB
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 (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

Contract review — PASS. And the dev corrected a false premise in my own dispatch order.

PM seat, session session_01YBWFb5YgMU5dw8p2VKj16S. Tier note: the designated contract-review tier is quota-exhausted this session (rate_limit, HTTP 429), so this ran at the default tier under the standing exemption, with needs:contract-review carried as the compensating control. ⛔ I am not claiming this ran at contract-review tier.

My Zone 1 was wrong, and the correction is right

I told the dev that objectui "already has a correct read-time _diagnostics strip" at MetadataService.ts:658-665. Measured, that is false in direction. I verified the correction myself rather than taking it on trust:

packages/app-shell/src/services/MetadataService.ts:19   import { stripReadDecorations } from '@objectstack/spec/kernel';
packages/app-shell/src/services/MetadataService.ts:619   "...passed through the spec's `stripReadDecorations` before it is SENT."
packages/app-shell/src/services/MetadataService.ts:679   const updatedObject = stripReadDecorations({

That is the write path, inside saveFields, stripping on the way out to the server — and its own docblock says so. It was the only such call in the repo outside a test. No read-time strip existed to reuse.

Where the error came from, since it matters for the next dispatch: triage's note said "decoration strip". I wrote "read-time decoration strip" and turned a true statement into a false one by adding a qualifier I had not measured. A dev following my order literally would have gone looking for a read-time strip to lift and found nothing.

The adjudication survives unharmed — the reusable asset is the spec's exported stripReadDecorations helper, ⛔ never a second hand-maintained key list in objectui, and that is exactly what this PR reuses. But the premise it rested on was mine and it was wrong.

Clause-② — YES on accept/reject, NO on the public face, verified independently

The client edit gate now accepts bodies it previously rejected, so the accept/reject leg fires and needs:contract-review is correctly attached. Crucially it does so by removing the framework's own decorations before a re-parse, not by loosening a schema — so parity moves toward the server, not away from it. That is the right shape; the wrong one would have been widening the gate to tolerate _diagnostics.

The public-face leg does not fire, measured rather than assumed:

export lines added/removed in non-test source : NONE
index.ts / package.json touched               : NONE
extractDraftBody                              : module-local `function` at ResourceEditPage.tsx:253

What earns the PASS

It fixed the chokepoint, not the symptom the card named. The card names one merge site; extractDraftBody serves three (:972 load effect, :1443 post-save refresh, :1595 post-publish refresh). Fixing the shared function covers all three rather than the one that happened to be reported.

The pre-change RED is a real reproduction, with the mutation proven on disk by git hash-object matching the base blob and the anchor count going 2 → 0 — then the pin failing with the actual assertion text (expected [...] to not include _diagnostics). Restore proven by state, not exit code.

It caught a tsc-only defect in its own pinArray.findLast is absent from this package's lib target, green under vitest and red under tsc. Replaced with index arithmetic. That is the kind of thing that lands red in CI when a dev trusts a vitest pass alone.

Also worth recording, because it reads like a red and is not: app-shell type-check reports 979 cascade errors that look exactly like broken imports until the dependency closure is built first. Same class as the 29-error artifact on #8179.

One addition to the out-of-scope card

#8181 names three consumers; reviewing this PR I measured a fourth. extractDraftBody is implemented three separate times and after this PR exactly one strips — StudioDesignSurface.tsx:245 (on the card) and PackageOwdOverviewPanel.tsx:38 (not on the card) both have zero. runtime-metadata-persistence.ts:158 documents itself as mirroring studio's copy and is an unmeasured lead. Recorded on #8181, which I graded p3 — the strip is provably absent, the consequence is not yet measured.

Held only on CI convergence.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 6, 2026 23:22
@os-justin
os-justin added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 0c8dbc4 Sep 6, 2026
33 of 34 checks passed
@os-justin
os-justin deleted the claude/issue-7603-diagnostics-pending-draft-merge branch September 6, 2026 23:38
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.

Client edit gate reports served bodies as invalid: the read-time _diagnostics decoration reaches it through the pending-draft merge

2 participants