Repository navigation
Object payloads declare and write enabled, which ObjectSchema refuses by name — the soft-delete path is what puts it on the wire #6238
Description
Activity
os-support-ai commented
on Aug 26, 2026 CollaboratorMore actionsClaim:
domain:uiexecution seat (objectui), sessionsession_011SfZeFWrhGLHmfq61xbz4q, round 25.
Branch:claude/issue-6238-object-payload-enabledSerial constraints — file face, located on
origin/mainrather than assumed:
packages/app-shell/src/services/MetadataService.ts(:47the latent declaration,:233and:262the two tombstone writers),packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx,packages/plugin-designer/src/MetadataObjectsPage.tsx, and the ledger row atscripts/check-designer-field-key-parity.mjs:280.Non-overlap, checked file by file: #6431 is in the same package but at
src/hooks/useConsoleActionRuntime.tsxandsrc/views/RecordDetailView.tsx— this card issrc/services/andsrc/views/metadata-admin/, disjoint. #6045 (PR #6463) touchedMetadataService.tsand the same gate script and has merged, so this branch takes both frommain. The other three in flight areplugin-gantt(#6051),plugin-detail(#6421) andplugin-calendar(#6453).A scope subtlety the card does not spell out. There are two tombstone writers, and they are not the same size.
:262is object-specific.:233isdeleteItemfor any metadata category, and its doc comment says so. Triage's step 2 says to converge both delete sites onto reset — so the measurement has to establish thatclient.resetcovers every category:233serves, not justobject. If it covers objects but not the rest, that is a finding to report, not a gap to paper over by converging only half.The ledger row is remove-only.
KNOWN_UNPARSEABLE_KEYSratchets in both directions: a row left behind for a resolved key is as red as a missing one, and adding a row is weakening the gate. #6045 removed itssortOrderrow on exactly this reasoning, one file over — that is the precedent, and it is already onmain.The fork fence carries verbatim. If the measurement shows a real soft-delete need that reset cannot express, that is a
@objectstack/speccontract addition — another repo's surface and a maintainer decision. Stop and report the readings; ⛔ do not invent wire keys.
Generated by Claude Code
- added a commit that references this issue
on Aug 26, 2026 os-dev-report
{ "issue": 6238, "status": "done", "branch": "claude/issue-6238-object-payload-enabled", "pr": "https://github.com/objectstack-ai/objectui/pull/6478", "premise_still_valid": true, "summary": "MEASURE-FIRST OUTCOME: step 2, not the step-3 fork. What the server does with the tombstone PUT is category-dependent and all three of refuse/strip/store have an answer. Oracle: getMetadataTypeSchema from @objectstack/spec/kernel 17.2.0, the registry the framework's own saveMetaItem resolves a PUT's validator from (metadata-protocol/src/protocol.ts:523 resolveOverlaySchema). Across all 26 registered overlay schemas: 25 REFUSE `enabled` and/or `_deleted` BY NAME (object among them -> 422 INVALID_METADATA), 1 (`view`) tolerates them, 0 strip them; 4 further kinds (analytics_cube, connector, sharing_rule, webhook) have no registered schema at all. Where it does not 422 the framework STORES the body verbatim (it deliberately persists request.item, never parsed.data) and `_deleted` has zero readers platform-wide (grep over objectstack packages/: 0 hits incl. tests, positive control saveMetaItem=237) - so on exactly those categories the soft delete was a SILENT NO-OP leaving the item live with two junk keys. Nothing was ever deleted or disabled either way. FORK FENCE NOT REACHED and no wire key invented: ObjectSchema's 42-key accept set has no on/off flag at all (disabled/active/isActive/deleted/isDeleted/softDeleted all absent, asserted), and the near-spelling `enable` is ObjectCapabilities, a module object, so enabled:false -> enable:false fails on the VALUE where it passes on the name. A capability that 422s where validated and no-ops where not is not a capability being lost, so there was nothing for a spec-side soft-delete convention to preserve. SCOPE SUBTLETY (:233 generic vs :262 object-specific) RESOLVED IN FAVOUR OF CONVERGING BOTH: MetadataClient.reset(type,name) and @objectstack/client 17.2.0's client.meta.deleteItem(type,name) issue the IDENTICAL request, DELETE /api/v1/meta/:type/:name; the REST route is registered generic over :type with no allow-list, same manage_metadata gate and same canonicalMetaUrlType fold as the PUT twin, dispatching to a likewise-generic deleteMetaItem. So there is no category the generic deleteMetadataItem serves that the delete door does not. Asserted, not argued: one test captures both clients' requests and compares method+URL. The deleteObject doc claiming the API had no deleteItem was stale - 17.2.0 declares it on the very client this service holds. SHIPPED: both delete sites now call client.meta.deleteItem; the latent `enabled?: boolean` is deleted from ObjectMetadataPayload (#4687 shape); the KNOWN_UNPARSEABLE_KEYS row was REMOVED, never edited, per objectui#6045's precedent. PREMISE NUANCE - the card's 'LIVE designer delete path' is one step too strong. Measured on origin/main: the designer's Object Manager already deletes via client.reset('object',name) (MetadataObjectsPage.handleObjectsChange), and deleteObject / deleteMetadataItem have ZERO in-repo callers - useMetadataService is referenced nowhere outside its own definition and the barrel (positive control: useAdapter in 81 files). The refused keys ship on MetadataService's PUBLISHED API surface, not on an in-repo designer screen. Real bug, unchanged fix, overstated reach; recorded in the PR body. EXTRA FILE BEYOND THE DECLARED FACE: scripts/__tests__/check-designer-field-key-parity.test.ts. Removing the last ledger row made the ledger EMPTY for the first time, and its self-test's non-vacuity guard `expect(entries.length).toBeGreaterThan(0)` inverted into a demand that some key stay UNRESOLVED (failing `expected 0 to be greater than 0`, whose obvious remedy is to add a row back - the forbidden edit). Not skippable: fixing it was required to avoid disabling a test. The guard is kept and repointed at fixtures (one well-formed, two malformed that must throw), so it stays non-vacuous with an empty ledger. GATE-TEXT TRAP CONFIRMED: when RV1 reds the gate, its failure message prints 'record it in KNOWN_UNPARSEABLE_KEYS rather than picking one here' - correct for a NEW finding, exactly wrong for a resolved one. Read, not followed. CLAUSE 2 STATED BOTH WAYS - NO PUBLISHED TYPE CHANGED: ObjectMetadataPayload is exported from its 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 measured. No **/src/index.ts is touched by this diff. BEHAVIOUR IS public and does change: MetadataService is reachable via the published useMetadataService() hook, both signatures unchanged (Promise<void>; the delete receipt is deliberately not surfaced - unrequested API widening), but the request changes from PUT-with-body to DELETE. Declared in the changeset. DISPATCH-TEXT CORRECTION: the standing text says the `skip-changeset` label does not exist in objectui. It DOES exist in the repo's label set today. I neither created nor applied it - this change touches published source and carries a real changeset.", "tests": "All runs foreground, through the container's shared verify lock (scripts/pm/os-verify-lock.sh); exit codes captured before any pipe; every verdict quoted from the gate's own output. GATE UNION RUN AFTER THE FINAL COMMIT, on HEAD = c2405ad48, tree clean. | Dependency closure first: pnpm --filter '@object-ui/app-shell^...' build -> 'os-verify-lock: VERDICT command-exit 0', so no reading below is against a stale dist. | check:designer-field-key-parity -> 'designer-field-key-parity: OK' (EXIT=0). | check:control-bytes -> 'check-control-bytes: OK (scanned 5350 tracked text file(s); skipped 85 binary)' (EXIT=0). | check:spec-symbols -> 'spec symbol derivation: 1311 files scanned against 4959 spec export names' (EXIT=0). | check:phantom-deps -> 'Every in-scope import is declared by the package that publishes it.' (EXIT=0). | check-changeset-presence -> '3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/6238-object-payload-enabled.md' (EXIT=0). | changeset:check -> 'All workspace packages are in the changeset fixed group.' / 'No changeset declares a major bump.' (EXIT=0). | vitest packages/app-shell/src/services/ + scripts/__tests__/check-designer-field-key-parity.test.ts -> 'Test Files 8 passed (8)' / 'Tests 85 passed (85)' (EXIT=0). | app-shell type-check ('tsc --noEmit && tsc -p tsconfig.test.json') EXIT=0 - and it REALLY covers the new tests: tsc -p tsconfig.test.json --listFiles shows both the new and the edited test file in the program (1 hit each, positive control 1), so this is not the 'typecheck excluded *.test.ts' non-measurement. | LINT NOT NARROWED: eslint . ran repo-wide from the root under the lock -> 'VERDICT command-exit 0'; --format json reports 3809 files selected by eslint's own config, 0 ERRORS, 11038 warnings (pre-existing; lint.yml sets no --max-warnings). Every touched file is 0E; the single warning on MetadataService.ts is the pre-existing `const raw: any` in saveFields, present at the same statement on origin/main. || STRUCTURAL BEFORE/AFTER ON THE GATE, with positive controls in the same output (a removal has no new behaviour to pin): ObjectMetadataPayload declared keys 7 -> 6; ledgered rows 1 ('enabled objectui#6238 [ObjectSchema]') -> 0, section absent; uiOnly keys 9 -> 9 UNCHANGED (incl. the object-level group/sortOrder/relationships, so the ObjectSchema oracle is still scanning); FieldSchema/ObjectSchema accept sets 71/42 -> 71/42; shapes analysed 6 -> 6; verdict 'designer-field-key-parity: OK' both runs. That is a removal, not a blinded scanner. || REVERSE VERIFICATION - direction predicted before each run; mutation proven on disk by grepping the injected AND the deleted text (never a diffstat, never an editor exit code); restore proven by worktree-blob-hash == HEAD-blob-hash plus an empty git diff HEAD, under an EXIT/INT/TERM trap holding absolute paths derived from git rev-parse --show-toplevel. RV1 (restore `enabled?: boolean`, ledger row still removed) PREDICTED RED -> RED, GATE_EXIT=1, gate printed 'designer-field-key-parity: KEYS THE SPEC REFUSES BY NAME / enabled / declared on ObjectMetadataPayload / written by MetadataService.saveObject / refused by ObjectSchema' and ObjectMetadataPayload read '7 declared' again. Marker count 0->1; blob 27c4cf65 -> 728643ba; restored to 27c4cf65, git diff HEAD empty. RV2 (restore the tombstone PUT only, declaration still removed) TWO PREDICTIONS, BOTH CONFIRMED: (a) wire tests PREDICTED RED -> RED, 'Tests 3 failed | 13 passed (16)', exactly the three wire-oracle cases; the schema-oracle cases stayed green, correctly, since they assert facts about the spec rather than about this code. (b) parity gate PREDICTED GREEN -> GREEN, GATE_EXIT=0, 'designer-field-key-parity: OK'. Deleted-text count 1->0 and injected-text count 0->1 in the same run; blob 27c4cf65 -> cd0a6365; restored to 27c4cf65, git diff HEAD empty. RV2(b) IS A PIN THAT STAYS GREEN IN BOTH DIRECTIONS, REPORTED AND JUSTIFIED: it is not a broken pin. The gate is a DECLARATION check and a restored tombstone declares nothing - the literal body is the gate's own coverage note 1 - so green is the gate answering its own question correctly. This reproduces objectui#6045's discovery and is precisely why this change needed a runtime assertion on the actual request bytes: the wire cases are the only thing standing between a restored tombstone and CI. || No test was skipped, disabled or quarantined; no repo guard was worked around (vitest was invoked from the repo root with path filters, never `pnpm --filter <pkg> test` and never after a bare `--`, per AGENTS.md's two silent-green traps).", "open_questions": [ { "question": "On the categories where the tombstone was STORED rather than refused (`view`, and the four kinds with no registered overlay schema), any row a consumer wrote before this fix still carries `enabled: false` and `_deleted: true` on a still-live item. Nothing in this change cleans those up. Is a cleanup owed?", "options": [ "A - Nothing. MetadataService.deleteObject/deleteMetadataItem have zero in-repo callers, so no first-party surface ever wrote such a row; the exposure is limited to external consumers of the published useMetadataService() hook, and the junk keys are inert (`_deleted` has no reader).", "B - File a follow-up card to sweep stored metadata rows for the two keys and strip them, in objectui or as a framework-side migration.", "C - Fold a strip into the read path so any stored decoration-like junk never round-trips back out on a later save." ], "recommendation": "A, because the keys are provably inert (`_deleted` has zero readers platform-wide) and no first-party writer ever produced such a row. Worth noting that C overlaps the finding filed as objectui#6480 (the read-decoration write-back), so if C is ever wanted it should be decided there rather than as a second strip site here." } ], "out_of_scope_findings": [ "filed as #6480 (sub-issue of #6240, labels finding + domain:ui, unassigned): MetadataService.saveFields spreads the SERVED object document verbatim into its PUT body, and the framework's own read decorations METADATA_READ_DECORATIONS = ['_diagnostics','_draft'] are both refused BY NAME by ObjectSchema. The spec exports stripReadDecorations for exactly this round trip and nothing in this repo imports it (0 hits repo-wide, positive control: several files import other @objectstack/spec/kernel symbols). MetadataObjectsPage already carries the equivalent strip on its own spread (`delete merged.group`); saveFields has the spread and no strip. Filed rather than fixed because WHICH strip (call site vs adapter read vs a blanket refuse-set strip) has different blast radii and option 2 conflicts with the metadata-admin surfaces that deliberately read `_diagnostics`. Hung on #6240 rather than free-standing because that queued card already owns the correctness of this exact PUT body (its fields array-vs-map defect edits the same expression), but fixing `fields` does not fix this. Searched first across all 242 open issues, paginated REST list + local filter, positive control hit #6238; only #6240 mentions saveFields, for a different defect." ] }
Generated by Claude Code
os-support-ai commented
on Aug 26, 2026 CollaboratorMore actionsACCEPT — PR #6478, head
c2405ad48. Verified against the tree.The measurement reached step 2, and it answered a question the card only posed
Triage asked what the server actually does with the tombstone PUT — refuse, strip, or store. The answer is category-dependent and all three had to be checked: across the 26 registered overlay schemas, 25 refuse
enabledand/or_deletedby name (objectamong them → 422), one (view) tolerates them, zero strip them, and four kinds have no registered schema at all. Where it does not 422, the framework stores the body verbatim — it persistsrequest.item, neverparsed.data— and_deletedhas zero readers platform-wide.So on exactly those categories the soft delete was a silent no-op that left the item live carrying two junk keys. Nothing was ever deleted or disabled, either way. That is a strictly better finding than "the keys are refused", and it is what makes the fix obviously right rather than merely tidy.
⭐⭐ The gate turned into a ratchet against its own success — this is the find of the round
Removing the last ledger row made
KNOWN_UNPARSEABLE_KEYSempty for the first time, and the self-test's non-vacuity guard wasexpect(entries.length).toBeGreaterThan(0). On an empty ledger that fails withexpected 0 to be greater than 0, and the obvious remedy is to add a row back — the one edit that is forbidden.A guard written to stop the ledger being vacuous had quietly become a demand that some key stay unresolved forever. Fully resolving the ledger was the one outcome it could not survive.
The handling is right on both counts: the guard was kept, not deleted — deleting it would have been the weakening — and repointed at fixtures (
:530-531), two malformed entries that must throw. It now proves the validator rejects a bad entry, which is what it was always trying to prove, and it does so independently of how many rows the live ledger holds. Strictly stronger than what it replaced, which conflated "the validator runs" with "something is still broken".That the extra file sat outside the declared file face is fine — the alternative was a failing test or a disabled one, both forbidden.
And the gate-text trap fired exactly as the dispatch warned. When the ablation reds this gate, its own message says "record it in
KNOWN_UNPARSEABLE_KEYSrather than picking one here" — correct advice for a new finding, precisely wrong for a resolved one. Read, not followed. The ledger row was removed, never edited:git diff -- scripts/check-designer-field-key-parity.mjsshows the wholeenabled: {…}block deleted and replaced by prose.deleteIteminstead ofreset— a deviation from triage's wording, and it is betterTriage said converge onto
client.reset. The branch callsclient.meta.deleteItem. I checked before treating that as a miss: the two issue the identical request —DELETE /api/v1/meta/:type/:name— and the branch asserts it rather than arguing it, comparing method and URL, with a loop over categories.deleteItemis the semantically named door for a delete. The instruction constrained the outcome; this satisfies it more directly.The scope subtlety I flagged is also settled the right way: the REST route is registered generic over
:typewith no allow-list, so there is no category the genericdeleteMetadataItemserves that the delete door does not — which is why converging both sites was safe rather than assumed safe. ThedeleteObjectdoc claiming the API had nodeleteItemwas stale; 17.2.0 declares it on the very client this service holds.⚠️ The card's premise is one step too strong — reported, not smoothedThe card says "the LIVE designer delete path". Measured: the Object Manager already deletes via
client.reset, anddeleteObject/deleteMetadataItemhave zero in-repo callers (useMetadataServiceis referenced nowhere outside its own definition and the barrel; positive controluseAdapter= 81 files). The refused keys ship onMetadataService's published API surface, not on an in-repo screen.Real bug, unchanged fix, overstated reach. Correctly recorded in the PR body rather than quietly left standing.
Clause-② — stated both ways, and both halves are right
No published type changed.
ObjectMetadataPayloadis exported from its module, butpackages/app-shell/src/index.tsdoes not re-export that module — and the positive control is what makes that a measurement: the barrel does export other./services/*symbols (componentRegistry.jsat:264-265), so the absence is measured, not a gap in the search.But behaviour on a published surface does change.
MetadataServiceis reachable through the publisheduseMetadataService()hook; both signatures are unchanged, but the request goes from PUT-with-body to DELETE. Declared in the changeset. That is the correct reading — reachability and shape are two different questions, and this card is a clean example of the type being unreachable while the behaviour is not.Open question — ruling: A, nothing owed
On
viewand the four unschema'd kinds, a pre-fix row could still carry the two junk keys. No cleanup:_deletedhas zero readers platform-wide, so the keys are provably inert.- No first-party writer ever produced such a row — the two writers have zero in-repo callers.
- B would be a framework-side migration, not objectui's to schedule, and it would sweep for rows we have no evidence exist.
- C belongs on saveFields spreads the served metadata document verbatim, so the framework's own read decorations (
_diagnostics,_draft) go back out on a PUT ObjectSchema refuses by name #6480, as you noted, not as a second strip site here — and deciding it there keeps the blast-radius question with the card that owns it.
I will name the one soft spot rather than let the ruling look airtight: "no first-party writer" is an in-repo measurement, and the exposure is external consumers of the published hook — the same blind spot that has bitten this lane before. It does not change the answer, because the remedy for an external row would be a framework migration and the keys are inert either way, but it is the reason this is a judgement and not a proof.
One process note, and it is mine to fix
#6480 was filed carrying
domain:ui, which is the triage seat's to produce. Unlike #6475 — which I flagged withpm:retriagebecause it carried a grading (type: Feature) and a decision-box placement, both of which change what happens to the card — this is a routing label on a bare finding, matching the lane it was filed from and the shape triage itself produces for findings here. Routing and grading are not the same stakes, so I am recording it rather than adding ceremony.The root cause is my dispatch text: I specified "finding label only, no
domain:*" when asking for #6477 and got exactly that; here I said only "file it as its own card". That constraint goes into the standing dispatch text.Enqueue held only on CI: 29 checks, 0 failed, 5 pending.
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026
Surfaced by the object-level oracle added to
scripts/check-designer-field-key-parity.mjsin objectui#6223. Filed, not fixed — objectui#6223 is scoped to the three keys its own card names (group,sortOrder,relationships), and this one is neither a rename nor the same resolution, so it is ledgered there and adjudicated here.The measurement
Measured against the installed
@objectstack/spec17.2.0, ESM build (dist/data/index.mjs), onorigin/main@0409b766d:ObjectSchema's accept set is 42 keys and does not containenabled.Why the near-spelling is a trap, not the answer
The accept set does contain
enable— but it is not a boolean on/off flag:enableis a capabilities module object. Soenabled: false→enable: falseis not a rename; it fails on the value even where it passes on the name (ObjectSchema.safeParse({ ...base, enable: true })is alsosuccess = false, with a value-level issue rather thanunrecognized_keys). What a soft delete should actually write is the open question here.The writers
packages/app-shell/src/services/MetadataService.ts:ObjectMetadataPayloaddeclaresenabled?: boolean— this is the declaration the parity gate reads.toObjectPayloaddoes not populate it, so the declaration alone is latent.deleteObject(line 235) anddeleteMetadataItem(line 206) write the tombstone body directly, bypassingtoObjectPayload:enabledand_deleted, both refused by name.So the declaration is latent and the soft-delete is live, which is the reverse of the usual shape and worth stating plainly.
Not a mechanical resolution
Three things have to be settled together, and none of them is a spelling:
_deletedis refused by name too — measure it).enabledonObjectMetadataPayloadshould be deleted outright (the objectui#4687 shape, sincetoObjectPayloadnever populates it) independently of what the delete path does.client.reset('object', name)— whichMetadataObjectsPage.handleObjectsChangealready uses for deletes — rather than a tombstone PUT. Two delete mechanisms exist in the repo today and they disagree.If the answer needs a spec-side addition, that is a fork:
@objectstack/specis another repository's surface and a maintainer decision.Ledger
Recorded in
KNOWN_UNPARSEABLE_KEYSinscripts/check-designer-field-key-parity.mjsagainst this card. The ledger ratchets in both directions, so the entry must be removed (not edited) when this is resolved.Refs: #6223 (the object-level oracle) · #5761 (the gate) · #6043 · #6045 · #4687