Skip to content

fix(app-shell): delete metadata items through the API's delete door, not a tombstone PUT - #6478

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6238-object-payload-enabled
Aug 26, 2026
Merged

fix(app-shell): delete metadata items through the API's delete door, not a tombstone PUT#6478
os-support-ai merged 1 commit into
mainfrom
claude/issue-6238-object-payload-enabled

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#6238

MetadataService's two delete methods PUT a hand-written tombstone that the metadata API refuses by name. Both now call the API's own delete door, and the latent enabled?: boolean on ObjectMetadataPayload goes with them. The card asked for measurement before any fix and put a fork fence on a spec-side soft-delete convention — the measurement is below, and it did not hit the fence.

1. What the server actually does with the tombstone PUT — refuse? strip? store?

All three questions have an answer, and it is category-dependent. Oracle: getMetadataTypeSchema from @objectstack/spec/kernel 17.2.0 — the registry the framework's own saveMetaItem resolves a PUT's validator from (resolveOverlaySchema, metadata-protocol/src/protocol.ts:523), so this is the schema the deployed route would use, type by type.

ObjectSchema.safeParse({ name, label, fields }) => success = true (control)
ObjectSchema.safeParse({ name, label, fields, isSystem: true }) => success = true (control)
ObjectSchema.safeParse({ name, enabled: false, _deleted: true }) => success = false
unrecognized_keys ["enabled","_deleted"]
invalid_type ["fields"]
across all 26 registered overlay schemas:
25 refuse `enabled` and/or `_deleted` BY NAME (`object` among them)
1 tolerates them (`view`)
0 strip them
plus 4 kinds with NO registered schema at all: analytics_cube, connector, sharing_rule, webhook
  • REFUSE — the strict majority, object included: 422 INVALID_METADATA. The delete recorded nothing, ever.
  • STORE, verbatimview (its schema tolerates unknown keys) and the four unregistered kinds (they fall through unvalidated). The framework deliberately persists the request item, not parsed.data — the comment at the safeParse site says so in as many words, to keep Studio-only auxiliary fields. And _deleted has no reader anywhere in the platform: grep -rn "\b_deleted\b" --include=*.ts packages/ over the framework returns 0, tests included, against a positive control of 237 for saveMetaItem. So on exactly the categories that did not 422, the "soft delete" was a silent no-op that left the item live carrying two junk keys.
  • STRIP — never. The one thing that would strip (parsed.data) is explicitly not used.

Neither outcome deleted or disabled anything. The near-spelling is a trap, as the card said: enable is ObjectCapabilities, a system-features module object, so enabled: falseenable: false fails on the value where it passes on the name — pinned as its own case, because a key-name gate can never state that.

2. Does client.reset cover it — for every category, not just object?

Yes, and the two are the same request.

wire call
MetadataClient.reset(type, name)DELETE /api/v1/meta/:type/:name
client.meta.deleteItem(type, name) (@objectstack/client 17.2.0)DELETE /api/v1/meta/:type/:name

The REST route is registered generic over :type with no allow-list, behind the same manage_metadata capability gate and the same canonicalMetaUrlType fold as the PUT twin, dispatching to a deleteMetaItem that is likewise generic. So the scope subtlety resolves in favour of converging both sites: :262's object-specific writer and :233's any-category deleteMetadataItem reach the same door, and there is no category the generic one serves that the delete route does not. This is asserted, not just argued — one case captures both clients' requests and compares method and URL.

MetadataService holds an ObjectStackAdapter, so its name for that door is client.meta.deleteItem. The doc comment claiming the API "exposes saveItem but no dedicated deleteItem" was stale: 17.2.0 declares it on the very client this service already holds.

3. The fork fence — not reached, and here is why

⛔ No wire key was invented. There was nothing to invent one for: ObjectSchema's 42-key accept set has no on/off flag (disabled, active, isActive, deleted, isDeleted, softDeleted all absent — asserted), and the tombstone expressed nothing any reader consumes. A capability that 422s where it is validated and no-ops where it is not is not a capability being lost. So this is objectui#4687's shape for the declaration and a mechanism change for the writers, with no spec surface required.

reset semantics are the overlay's, and that is the governed answer rather than a shortfall: it removes the customization row — which is deletion for an object the designer authored — and restores the artifact for one a package declares, an object you are not allowed to delete. Which of the two an item is, is what the API's own deletable / resettable verdicts report, not something a client-side flag should decide.

Premise check — one claim in the card is a step too strong

The card and triage both call this "the LIVE designer delete path". Measured on origin/main: the designer's Object Manager already deletes through client.reset('object', name) (MetadataObjectsPage.handleObjectsChange), and MetadataService.deleteObject / deleteMetadataItem have zero in-repo callersuseMetadataService is referenced nowhere outside its own definition and the barrel, against a positive control of 81 files for useAdapter.

So the refused keys ship on MetadataService's published API surface (useMetadataService(), exported from app-shell's barrel), not on an in-repo designer screen. The bug is real and the fix is unchanged — but "live designer path" overstates the reach by one step, and the card's own framing of the declaration as latent and the soft-delete as live is what led there.

Structural claim: a measured before/after on the gate

A removal has no new behaviour to pin, so the ledger claim is a before/after with its positive controls in the same output.

beforeafter
ObjectMetadataPayload declared keys76
ledgered rows1 (enabled objectui#6238 [ObjectSchema])0 — section absent
uiOnly keys reported99 (unchanged)
FieldSchema / ObjectSchema accept sets71 / 4271 / 42
shapes analysed66
verdictdesigner-field-key-parity: OKdesigner-field-key-parity: OK

The nine uiOnly keys — including the object-level group, sortOrder and relationships — are still reported afterwards, so the ObjectSchema oracle is still scanning and this is a removal rather than a blinded scanner.

The gate's failure text offers the one edit the ledger forbids. When RV1 below turns it red it prints, ready to act on, "record it in KNOWN_UNPARSEABLE_KEYS rather than picking one here" — correct advice for a new finding, and exactly wrong for a resolved one, where adding a row weakens a gate that ratchets in both directions. Read, not followed. The row was removed, per objectui#6045's precedent one file over.

The ledger is now empty for the first time, which is the ratchet arriving where it was pointed — and that surfaced a real defect in the gate's self-test: expect(entries.length).toBeGreaterThan(0), written as a non-vacuity guard, had inverted into a demand that some key stay unresolved, failing with expected 0 to be greater than 0 whose obvious remedy is to add a row back. The guard is kept and pointed at the right thing — the per-entry validation now runs against fixtures (one well-formed, two malformed that must throw), so it is non-vacuous whatever the live ledger holds.

⭐ Which half the gate cannot see

objectui#6045 found the parity gate stays green on a tree where only the copier is restored, because it reads declarations. That applies here with more force: the tombstone was a literal object, not a declared shape — the gate's own coverage note 1. Reverse verification confirms it, and that is why this change needs a runtime assertion on the actual request bytes, not only a declaration removal.

Reverse verification

Direction predicted before each run. Mutations proven on disk by grepping the injected and the deleted text; restores proven by worktree-blob-hash ==HEAD-blob-hash plus an empty git diff HEAD, under an EXIT/INT/TERM trap holding absolute paths.

RV1 — restore enabled?: boolean, ledger row still removed. Predicted RED.RED, GATE_EXIT=1:

designer-field-key-parity: KEYS THE SPEC REFUSES BY NAME
enabled
declared on ObjectMetadataPayload (packages/app-shell/src/services/MetadataService.ts)
written by MetadataService.saveObject
refused by ObjectSchema

ObjectMetadataPayload reads 7 declared again. Marker count 0→1; blob 27c4cf65728643ba; restored to 27c4cf65, git diff HEAD empty.

RV2 — restore the tombstone PUT only, declaration still removed. Two predictions.

  • (a) wire tests REDRED, 3 failed / 13 passed. Exactly the three wire-oracle cases; the schema-oracle cases stayed green, which is correct — they assert facts about the spec, not about this code.
  • (b) parity gate GREENGREEN, GATE_EXIT=0, designer-field-key-parity: OK.

(b) is a pin that stays green in both directions, and it is reported rather than filed off. It is not a broken pin: the gate is a declaration check and the restored tombstone declares nothing, so green is the gate answering its own question correctly. It is the honest limit the gate's header states, and the reason the wire cases exist — they are the only thing standing between a restored tombstone and CI.

Deleted-text count 1→0 and injected-text count 0→1 in the same run; blob 27c4cf65cd0a6365; restored to 27c4cf65, git diff HEAD empty.

⭐ Clause ② — public surface, stated either way

No published type changed.ObjectMetadataPayload is exported from its own module, but that module is not re-exported by packages/app-shell/src/index.ts — the package's only entry (exports: { "." → dist/index.d.ts }). Positive control: the barrel does export other ./services/* symbols (componentRegistry), so the absence is a measurement rather than a failed grep. No **/src/index.ts in this repo is touched by this diff at all.

Behaviour, however, is public and does change — stated because it would be wrong to leave it inferred. MetadataService is reachable through the published useMetadataService() hook. Both method signatures are unchanged ((…) => Promise<void>; the delete receipt is deliberately not surfaced — that would be an unrequested API widening), but the HTTP request they issue changes from PUT /meta/:type/:name with a body to DELETE /meta/:type/:name. Declared in the changeset.

Verification

All commands run in the foreground through the container's shared verify lock; exit codes captured before any pipe; each verdict quoted from the gate's own output. Gate union run after the final commit, on c2405ad48, against a clean tree:

gateverdict line
check:designer-field-key-paritydesigner-field-key-parity: OK
check:control-bytes✅ check-control-bytes: OK (scanned 5350 tracked text file(s); skipped 85 binary)
check:spec-symbols✅ spec symbol derivation: 1311 files scanned against 4959 spec export names
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
changeset presence✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
changeset:check✅ All workspace packages are in the changeset fixed group. / ✅ No changeset declares a major bump.
vitest (app-shell services + gate self-test)Test Files 8 passed (8) · Tests 85 passed (85)
app-shell type-checktsc --noEmit && tsc -p tsconfig.test.json, EXIT=0

Lint was not narrowedeslint . ran over the whole repo from the root: 3809 files selected by eslint's own config, 0 errors, 11038 warnings (pre-existing; .github/workflows/lint.yml sets no --max-warnings). Every file this PR touches is 0 E; the single warning on MetadataService.ts is the pre-existing const raw: any in saveFields, present on origin/main at the same statement.

The type-check really covers the new test file — the package's type-check is tsc --noEmit && tsc -p tsconfig.test.json, and --listFiles confirms both the new and the edited test file are in that program (1 hit each, positive control 1), so "typecheck clean" is not a claim about a program that excluded them.

Dependency closure built before any type-check or ablation (pnpm --filter '@object-ui/app-shell^...' build, VERDICT command-exit 0), so no reading here is against a stale dist.


Generated by Claude Code

…not a tombstone PUT
`MetadataService.deleteObject` and `deleteMetadataItem` wrote
`{ name, enabled: false, _deleted: true }` through `client.meta.saveItem`, i.e.
`PUT /api/v1/meta/:type/:name`. Measured against the installed
`@objectstack/spec` 17.2.0 via `getMetadataTypeSchema` — the registry the
framework's own `saveMetaItem` resolves a PUT's validator from — 25 of the 26
registered overlay schemas refuse `enabled` and/or `_deleted` BY NAME, `object`
among them, so that call was a hard 422 `INVALID_METADATA` and recorded nothing.
Where the schema is tolerant (`view`) or unregistered the framework stores the
body verbatim and nothing on the platform reads `_deleted`, so there the "soft
delete" was a silent no-op that left the item live carrying two junk keys.
Nothing strips them, and there is no third outcome.
Not a rename, because there is nothing to rename to: `ObjectSchema`'s 42-key
accept set has no on/off flag, and the near-spelling `enable` is
`ObjectCapabilities`, a system-features module object, so `enabled: false` ->
`enable: false` fails on the value where it passes on the name. No wire key was
invented — a metadata soft-delete convention would be a `@objectstack/spec`
contract addition and the platform does not have one.
Both sites now call `client.meta.deleteItem(type, name)`, which issues the same
`DELETE /api/v1/meta/:type/:name` that `MetadataClient.reset` does — the
mechanism `MetadataObjectsPage.handleObjectsChange` and `ResourceEditPage`
already used. That route is generic over `:type` on the same route family and
capability gate as the PUT, so the convergence holds for every category the
generic `deleteMetadataItem` serves, not just `object`. The doc comment claiming
the API had no `deleteItem` was stale: `@objectstack/client` 17.2.0 declares it
on the very client this service already holds.
The latent `enabled?: boolean` on `ObjectMetadataPayload` goes with it
(objectui#4687's shape — `toObjectPayload` never populated it), and so does the
`KNOWN_UNPARSEABLE_KEYS` row, which ratchets in both directions. The ledger is
now empty for the first time, so the self-test's non-vacuity guard moved onto a
fixture: `expect(entries.length).toBeGreaterThan(0)` had inverted into a demand
that some key stay unresolved.
Part of #6238
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.4 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-9Jg8e8DD.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.30KB4.28KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.99KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.26KB62.38KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.82KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 04:26
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 622f33cAug 26, 2026
30 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6238-object-payload-enabled branch August 26, 2026 04:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Object payloads declare and write enabled, which ObjectSchema refuses by name — the soft-delete path is what puts it on the wire

2 participants

@os-support-ai@claude