fix(data-objectstack): apply the object-metadata write invariant at the write doors, not at an enumeration of writers - #9238
Conversation
…he doors, not the writers objectui#7714 ruled one client behaviour -- a half-filled relationship stays client-side and the PUT body never carries one without a non-empty `reference` -- and its PR implemented that ruling by ENUMERATING the writers it knew of. Two. objectui#8057 then reproduced the identical defect on a third writer in that card's own required dogfood, and a sweep found nine more. The half that outlives the count: the sweep the question is naturally asked in cannot see its own subject, because the call is `client.save<any>(type, ...)` and a hand-rolled `fetch` PUT is not that spelling at all. So the invariant moves off the writers, which are an OPEN set nobody has to announce a member of, and onto the DOORS, which are a CLOSED set this repo owns. Three in-repo transports can PUT /meta/:type/:name; all three now apply the same assertion, so every writer is covered with no list existing anywhere. - `assertObjectMetadataWritable` in @object-ui/data-objectstack, applied by `MetadataClient.save` before the request, by `importObjectDraft`'s hand-rolled PUT, and by MetadataService's one SDK seam. - `scripts/check-object-metadata-write-doors.mjs` derives the door set on every run -- resolving each URL through templates, fields and helper returns, because the central door says nothing at its own call site -- and fails when an object-capable door does not reach the guard. - The relationship-type list, previously declared word-for-word in two writers, is now one declaration with a pin that DERIVES it from the installed @objectstack/spec. Part of #8676 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
…ec-pin ledger row The ledger row for `MetadataService.ts` said three `@objectstack/spec` 17.2.0 citations; the tree now has two. The third sat on the relationship-type list's docblock, which moved to `object-metadata-write-guard.ts` and deliberately did NOT take the version stamp with it -- the claim it stamped is re-measured on every run by that module's derivation pin instead of recalled at a version. Part of #8676 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
…ive-metadata-write-guard
✅ Console Performance Budget
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
Size Limits
|
PM ruling on both open questions — and Q2 was a batch defect of mine, across four cards, one of them already merged
Q2 — the misplaced
|
| card | carries a Clause-②: line |
is a claim comment |
|---|---|---|
| objectui#8676 | ✅ | ❌ |
| objectui#8348 | ✅ | ❌ |
| objectui#8365 | ✅ | ❌ |
| objectui#8442 | ✅ | ❌ |
--pair 9229. ⇒ the label limb passing is not evidence the content limb would.
All four claim comments are patched (the bare marker line inserted, the existing **Clause-②: yes** line untouched — it parses; readValueToken accepts yes followed by * because the boundary is (?[A-Za-z0-9_])). --pair 9238 now reads ✓.
⇒ The thinking was done every time and written down every time; it was written in a shape the predicate does not look at. Which is rule 14 again — 做过≠记过 — one layer down: not "did the review happen" but "is the record in the shape the reader reads".
Q1 — does objectui#9237 re-price this card? → A: no, and objectui#9237 carries the severity instead
⛔ I want to be explicit that my own trigger fired, because quietly narrowing it would be the dishonest move. I dispatched this with: "if you find a reachable path where an unguarded write actually corrupts stored metadata, say so and ask for it to be re-priced." The seat found one and asked. The letter fired.
The intent did not, and the reason is measured rather than argued. p2 was assigned to this card for guard coverage — object-metadata writes that bypass objectui#7714's invariant. objectui#9237 is a field deleted during serialisation, before any door sees the body: writeFields builds with out[e.name] = e.def, so a spec-legal stored field named __proto__ fires the prototype setter instead of creating a key, the PUT body loses the field, and the spec accepts the result — with the control showing two ordinary names losing nothing.
⇒ A door guard judges the body it is handed, and a field already dropped is not in the body. This card's guard would pass that write at 100% coverage. That is a boundary of the design, not a hole in it, and the seat named it as such rather than claiming coverage it does not have. ⇒ the two defects are genuinely different, and pricing this card on the other one's severity would misdescribe both.
⛔ Not C, and the seat's reason is exactly right and worth preserving: the fix is one line but its evidence bar is not — the pin has to carry a prototype-reachable name with a control, and the producer half is NOT MEASURED. Folding a p1-shaped defect into a PR graded on coverage would hide it in a changeset about something else.
toFieldName sanitises it away, so the producer is the CLI, a seed package, AI-authored metadata, another client — ⭐ or this repo's own guarded writer, since MetadataService.toFieldsMap uses Object.fromEntries precisely so it can write that key correctly. One writer creates the state another destroys. That sentence is the card.
⭐ The design answer, which is the reason this card existed
The invariant moves off the writers — an open set nobody announces a member of — and onto the doors, a closed set this repo owns.
That is the durable half discharged rather than worked around. And the two numbers that did not survive the fresh census are both in the direction the card predicted: the card's own filter misses MetadataService.saveMetadataItem (a runtime category that can be 'object', and (category is in none of the filter's alternations), and the strongest instance — importObjectDraft PUTting /api/v1/meta/object/:name by hand-rolled fetch — is invisible to a .save-shaped sweep of any spelling. ⇒ the card's thesis proven by its own count failing.
⭐ And the fix's one remaining enumeration gets no exemption from the card's lesson: RELATIONSHIP_TYPES_REQUIRING_REFERENCE was declared word-for-word in two writers with a pin existing only to notice drift; both now import one declaration, and a pin derives it from the installed @objectstack/spec.
Contract review follows.
Generated by Claude Code
⛔ CI is RED on
|
| check | failing step | command |
|---|---|---|
Lint |
Verify every scripts/ entry guard goes through one predicate |
node scripts/check-entry-guard.mjs --self-test then node scripts/check-entry-guard.mjs (lint.yml:238-239) |
Doc Snippet Type Check |
Compile documentation snippets against the built types | node scripts/check-doc-snippet-types.mjs (doc-snippet-types.yml:180) |
scripts/check-object-metadata-write-doors.mjs not going through the shared invoked-as.mjs entry-guard predicate; the second compiles against built dist/*.d.ts, and this PR adds three published exports. @example blocks was skipped, so it may red next — both need running.
⭐ This is not a flake and it is not the base branch: it is red on this head, in files this diff introduces. ⛔ Not re-run, ⛔ no gate weakened or exempted, and ⛔ objectui#9237's writeFields fix is still not folded in — the ruling above stands.
Handed back to the delivering seat with both commands, the build prerequisite, and the instruction to keep the fix minimal. origin/main has also moved since the merge at 973cc0d12 — now 3a43a15e63, carrying objectui#9229 (objectui#8442's delivery, which touches @object-ui/types and app-shell) — so it re-merges before pushing.
The substance of the delivery is already reviewed and accepted; the contract review of record will be posted against the green head, since clearing a gate against a head CI has not passed is exactly the shape this lane grades as C3.
Generated by Claude Code
…ive-metadata-write-guard
…icate, and make the README snippet compile
Two CI reds, both in code this PR adds, and both in gates I had not run in the
form CI runs them.
1. `check:entry-guard` — the new gate hand-typed its own
`process.argv[1]` comparison. Node resolves symlinks for the module graph
but leaves `process.argv[1]` as the caller typed it, so a hand-typed guard
reached through a symlink answers false and the gate does NOTHING: exit 0,
no output, indistinguishable from a pass to a wrapper holding only
`result.status`. Now `isEntrypoint(import.meta.url)` from
`scripts/invoked-as.mjs`, the one predicate, like its 92 neighbours. The
now-unused `resolve` import is dropped with it.
2. `Doc Snippet Type Check` — the README's second example called
`assertObjectMetadataWritable('object', body, ...)` with no `body` in scope
(TS2304). It is now a complete function that also shows the shape the
example is about: guard first, then the PUT.
⛔ Neither gate was weakened or exempted, and no ledger row was added:
`KNOWN_HAND_TYPED_GUARDS` and `UNGATED_EXAMPLES` are both shrink-only.
Part of #8676
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
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
Size Limits
|
Contract review — head
|
Part of #8676
The design answer: doors, not writers
objectui#7714 ruled one client behaviour — a half-filled relationship stays client-side and the PUT body never carries one without a non-empty
reference— and its PR implemented that ruling by enumerating the writers it knew of. Two. objectui#8057 then reproduced the identical defect on a third writer in that card's own required dogfood, and objectui#8676 swept and found nine more. The sentence that outlives all three cards:⛔ So this PR does not enumerate writers, and it does not add a bigger list. The writer set is OPEN — any component may decide to save an object, and nothing tells anyone when one is added. The door set is CLOSED and this repository owns it: bytes reach
PUT /meta/:type/:nameonly through code in this tree. Guard the doors and the writer count stops mattering.Three in-repo transports can PUT object metadata. All three now apply the same assertion:
.save(-shaped sweep sees itMetadataClient.savepackages/data-objectstack/src/metadata-client.tsclient.save('object', …)writer arrives hereimportObjectDraftpackages/app-shell/src/views/metadata-admin/external/api.tsfetchPUT, not a.savespelling at all@objectstack/clientSDK'smeta.saveItemMetadataService's one seamFresh census — measured on this branch, ⛔ not the card's
The card's 12-of-15 was 2026-09-08 on
4eb665bcf. Re-measured onb775500af, population 4194 files underpackages/*/src/*and 197 underapps/*/src/*(the card had 3962 / 191 — the tree moved). Lit control first, as the card did:git grep -ln 'doSave'over the same population returns 10 files, so the pathspec is live before any zero is read.The card's own filtered command, run verbatim, still returns 15 call sites in 10 files — but the count is not the finding, and two of the card's own numbers do not survive contact:
MetadataService.saveMetadataItemwritesclient.meta.saveItem(category, name, data)with a runtimecategorythat can be'object', and it is public API throughuseMetadataService. The card's sweep filters on'object'/(type/(parentType/(current.type/save<, and(categoryis in none of them. A sweep whose filter is a hand list of spellings under-reports for exactly the reason the card is about..save-shaped sweep of any spelling.importObjectDraftPUTs/api/v1/meta/object/:nameby hand-rolledfetch, with a docblock that says it is "mirroringMetadataClient.save". It is reachable from a real UI surface (ImportObjectDialog). No.save[<(]or.saveItem(pattern can see it.So the derivation is over transports, not over call spellings.
scripts/check-object-metadata-write-doors.mjsnow reports, re-derived on every run:MetadataClient.savewritesthis.fetchImpl(url, { method: 'PUT' });urlis a template overthis.base, which the constructor sets frombuildBase(config), which returns a template overMETA_PREFIX. Ablation leg C below shows what a gate that reads the call instead gets: a sereneOK, exit 0, over a census with the repository's central metadata door missing.⭐⭐ A reachable path where an unguarded write CORRUPTS stored metadata — please re-price
The dispatch asked for this prominently rather than quietly at p2. Filed as objectui#9237, measured through the real exports on
b775500af:writeFieldsbuilds its map without[e.name] = e.def, so a spec-legal field named__proto__invokes the prototype setter instead of creating a key and is dropped beforeJSON.stringifyever sees it. Two unguarded writers PUT that output. Every other defect in this family has been a loud 422; this one succeeds, stores the object without the field, and reports nothing.What changed
packages/data-objectstack/src/object-metadata-write-guard.ts—assertObjectMetadataWritable(type, body, door). It asserts objectui#7714's invariant and nothing else: ⛔ not a client-side revalidation of the document throughObjectSchema(that would promote a client PREDICTION to a block, which objectui#4306 / objectui#6980 ruled against, and would refuse plugin-registered keys the server accepts), and ⛔ not strip-and-report-saved (objectstack#4001's silent-drop shape, ruled out for this family twice). It reads bothfieldsshapes, because one whole designer surface PUTs the array form verbatim.MetadataClient.savebefore the request;importObjectDraftbefore itsfetch;MetadataService's threemeta.saveItemsites funnelled through one private seam, since that door lives in a package this repo does not own and cannot be guarded from the inside.scripts/check-object-metadata-write-doors.mjs+pnpm check:metadata-write-doors, wired intoci.ymlbeside the siblingtypescript-parsing gates, documented in the CI guide. It refuses to report OK unless it found at least one door of each kind and at least one guarded door, so a renamed transport turns it red rather than green.RELATIONSHIP_TYPES_REQUIRING_REFERENCEwas declared word-for-word inMetadataService.tsand inMetadataFieldsPage.tsx, with a pin existing only to notice drift between them. Both now import one declaration, andobject-metadata-write-guard.derivation.test.tsDERIVES it from the installed@objectstack/spec— every member ofFieldTypeparsed throughFieldSchema, keeping those refused at pathreference— so a spec release that adds a third relationship type reddens CI instead of leaving the guard quietly short of the contract. The fix's own one enumeration does not get an exemption from the card's lesson.MetadataClient.save('object', …)now throws before issuing the request when the body carries a relationship field with a missing / empty / whitespace-onlyreference. The argument that this forecloses nothing:@objectstack/spec17.4.0 refuses the identical document at the server with a 422 onfields.NAME.reference— re-measured in the derivation pin, for all four states, with an accepting control beside them. Nothing that previously succeeded now fails.saveRefusalrather than replacing it: this is a pre-flight for one known class, that is a post-flight for everything the server refuses.assertObjectMetadataWritable,RELATIONSHIP_TYPES_REQUIRING_REFERENCEandOBJECT_METADATA_TYPEare new exports of@object-ui/data-objectstack.Bump call —
minor, and ⛔ not by the lane's usual testThe lane's test is does existing stored data render differently. It does not — rendering is untouched, and no stored document is read differently. So that test alone would say
patch. The level isminorfor a different reason it cannot express: the published surface gains three exports and a published door gains a refusal it did not have.patchcannot carry an API addition, and AGENTS.md's version policy routes objectui's own breaking semantics tominorrather thanmajor(the fixed group follows objectstack's major). Stated here rather than copied from a neighbouring changeset.Verification
All at
4e03a42fc, after a secondgit merge origin/main(clean, no conflicts;origin/mainwas432882b82, and objectui#9229 — objectui#8442's delivery — landed in it without touching any file on this PR's face).973cc0d12and are fixed here, both in code this PR adds and both in gates this seat had not run in the form CI runs them:Lint→ "Verify everyscripts/entry guard goes through one predicate" (node scripts/check-entry-guard.mjs). The new gate hand-typed its ownprocess.argv[1]comparison. That is the one spelling this repository forbids, and the reason is the same failure direction the gate itself is about: node resolves symlinks for the module graph but leavesprocess.argv[1]as the caller typed it, so a hand-typed guard reached through a symlink answersfalseand the gate does nothing — exit 0, no output, which a wrapper holding onlyresult.statuscannot tell apart from a pass. It now usesisEntrypoint(import.meta.url)fromscripts/invoked-as.mjs, like its 92 neighbours; the then-unusedresolveimport went with it.✓ check:entry-guard: 93 scripts/ file(s) — no entry guard outside the baseline; 0 file(s) still hand-type oneexit 0, self-test63 cases passexit 0.Doc Snippet Type Check(node scripts/check-doc-snippet-types.mjs). The README's second example calledassertObjectMetadataWritable('object', body, …)with nobodyin scope —TS2304: Cannot find name 'body'atpackages/data-objectstack/README.md:744.dist/*.d.ts, so an unbuilt tree answersexit 2with[unbuilt-package]for all 34 packages — PREREQUISITE NOT MET, ⛔ not a pass and ⛔ not a red. Reproduced afterpnpm build(43 successful), then fixed by making the snippet a complete function that also shows the shape it is about: guard first, then the PUT.Every covered documentation snippet compiles against the built types—646 of 646 block(s) judged, 0 failed, exit 0. Its two siblings were run as well:--emit-censusexit 0, andcheck-doc-example-types.mjs(which had been skipped on the red run)Every covered @example compiles, or fails exactly as its ledger row declares, exit 0.⛔ Neither gate was weakened, narrowed or exempted, and no ledger row was added —
KNOWN_HAND_TYPED_GUARDSandUNGATED_EXAMPLESare both shrink-only, and both still read 0 additions.scripts/entry point was added at all, and that a README code block was edited at all. The card's own lesson applies to the seat as much as to the tree.Suites, at
4e03a42fc—pnpm exec vitest run packages/data-objectstack/ packages/app-shell/ packages/plugin-designer/ scripts/__tests__/→Test Files 1 failed | 920 passed | 2 skipped (923),Tests 1 failed | 12102 passed | 3 skipped (12106),806.71s. (The same union at973cc0d12read1 failed | 919 passed (922)/1 failed | 12091 passed (12095).)e4b0a30be,Test Files 2 failed | 917 passed,Tests 2 failed | 12070 passed) is superseded; both of its failures are accounted for:check-installed-spec-pin-claims— mine, and fixed in2981c4ade. The ledger row saidMetadataService.tscarried three@objectstack/spec17.2.0 citations; moving the list's docblock out left two. Re-counted, with the reason recorded in the row. Gone from the run above.check-side-effects-array.test.ts— not mine, and already open as objectui#9124 (same signature: reds on any tree wherepackages/layout/dist/index.umd.cjsexists). ⭐ Proved by a control leg rather than asserted: with the root manifest restored byte-for-byte to the base —git hash-object package.json=a1ad28128db36f223410fd408036a2e14046bd97, identical tob775500af:package.json— the same test is still1 failed | 43 passed. It is also still red after a fullpnpm build. And the GATE itself (pnpm check:side-effects-array) is exit 0; only that one test assertion reds. It is the single remaining failure above.New suites, also at
4e03a42fc:77 passed (77)— now includingscripts/__tests__/entry-guard-wiring.test.ts, which is the suite that would have caught red 1 — across the guard's behaviour pins, the spec-derivation pin, the door pin, the gate's own suite, the entry-guard wiring suite and the spec-pin-claims suite.Gates (own verdict lines, exit captured by redirect-then-capture, ⛔ never behind a pipe):
turbo run type-checkfor the three packages: exit 0.turbo run lintfor the three:0 errors(2994 pre-existing warnings).pnpm lint:root:0 errors.pnpm build:43 successful.NOT MEASURED locally, declared to CI: the eager-closure / bundle budget family (needs a console bundle analysis), the full
pnpm testshards outside the three packages plusscripts/__tests__, andnode-esm-load's load leg.Ablation — five legs, each from the COMMITTED tree
Every leg: mutate → prove it reached disk in both directions (anchor count 1 → 0 and injected count → 1, plus
git hash-objectdiffering from the HEAD blob and a line-total check) → run → restore withgit checkout HEAD -- ABSOLUTE_PATH(⛔ never a baregit checkout --, which takes the mutation back out of the polluted index) → restore proved by STATE,git diff HEADempty, on all five.trap ... EXIT INT TERMwith absolute paths throughout.metadata-client.ts:967as unguarded. The two lit controls stay green, as they must..trim()droppedOK ... 16 doors ... 2 can carry an object documentand exits 0 — a confident green over a census missing the repo's central door. Two pins catch it: the fixture rebuilt in that indirection shape, and the coverage row naming the three doors.['lookup'])Acceptance notes
writeFieldssilently deletes a spec-legal__proto__field from the PUT body and the spec ACCEPTS the result. Measured with controls; unassigned and unlabelled. ⭐ Please price it, and consider whether it moves this card's own grade.check-side-effects-array.test.tsis red onorigin/main. Already open as objectui#9124 (same signature: reds on any tree wherepackages/layout/dist/index.umd.cjsexists). Carrier: objectui#9124's claimant.describeUnusableTargetandassertRelationshipTargetPresentare still duplicated word-for-word betweenMetadataService.tsandMetadataFieldsPage.tsx. Only the type LIST was de-duplicated here, because that is what the guard's placement forced; the message functions are pinned verbatim by twospecKeyReferencesuites and collapsing them is a separate change with its own pin work. Carrier: whoever next edits either refusal message — both docblocks already name the sibling.fetchwhose method string arrives in a variable. Both are stated as boundaries in the script's own docblock. Carrier: none today — no such door exists in the tree.⛔ Not enqueued and no auto-merge: this PR carries
needs:contract-reviewunder the dispatch's Clause-② declaration, and the PM lands it.Session, as prose so an edit cannot strip it:
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Generated by Claude Code