Repository navigation
fix(metadata-protocol)!: refuse a keyed seed whose externalId names no field of its object - #29066
Conversation
…o field of its object The save door, the publish promotion and the restore doors refuse an upsert/update/ignore seed whose externalId (each composite member; the default `name` when undeclared) is not a field of the seed's object, 400 VALIDATION_ERROR at externalId. The boot loader never refuses: it warns once for a keyed dataset whose every record keys empty. Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude <noreply@anthropic.com>
…d on a field its object lost Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude <noreply@anthropic.com>
…contract ledger Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin eead2632f1967d13800b3fd5f4d11c09e3bda387 && git checkout eead2632f1967d13800b3fd5f4d11c09e3bda387
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5d3667e292c67642a99c2ce1b02950af9469a82f 9127f436332169631f997380f1a74079598e612f && git checkout -B drift-repro 5d3667e292c67642a99c2ce1b02950af9469a82f && git merge --no-ff 9127f436332169631f997380f1a74079598e612f
node scripts/docs-audit/affected-docs.mjs --json 5d3667e292c67642a99c2ce1b02950af9469a82f
|
…ave door's 422 INVALID_METADATA envelope One door, one shape: the refusal now carries the envelope the unknown datasource refusal uses (422 INVALID_METADATA, "TYPE/NAME failed spec validation", issues at externalId / externalId.N with the prose in each finding), at the save door, the publish promotion and the restore doors. Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract-review-claim: Taken by the dispatching seat. Face Generated by Claude Code |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract reviewServed-tier: Read at 2026-11-02T07:20Z: card #28952 (body, 3 comments), PR #29066 (v2 body, 10 files, net diff vs ① Derived judgmentsAccept-set changes the diff implies, each judged against the cited code at the head:
② Semver levelChangeset ③ Boundary flags
④ Design judgmentSpec on
Check-runs at 2026-11-02T07:20Z: success — Check Changeset, PR-body guards, Governed Surface Queue Guard, Spec property liveness, Build Core, Test UI, Live E2E UI, Dogfood Regression Gate, Type Check · source gates / debt ledger, every Lint lane except docs & code guards; failure — Implemented-by: VERDICT: FAIL Generated by Claude Code |
The fixture's text column was named code, so a record literal read as a lowercase error code to the ADR-0112 casing gate. Renamed to sku; no behaviour change. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
Delta 3d04cc7e..9127f436 |
One test-only commit, read by the seat: in protocol.seed-external-id-refusal.test.ts the fixture column code became sku (object fixture, records, externalId cases, the stored-row assertion). No source, changeset or body change, so nothing the review judged moves. pnpm check:error-code-casing exits 0; that file passes 22/22. |
| Left for later | The review's should-fix (a seed keyed on a field that only a pending object_extension draft adds is refused) is recorded in the seat 1 handoff on #6367, not on this PR. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z), and 「先确保已有的pr都合并」 (Leehom, 2026-11-02T07:11Z): ready and auto-merge through the relay; CI on this head decides. |
Generated by Claude Code
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: Taken under Generated by Claude Code |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract reviewServed-tier: Read: card #28952 (body, 5 comments), PR #29066 (body, 10 files, net diff vs The lane's FAIL record on ① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged against the cited code at this head:
② Semver levelChangeset ③ Boundary flags
④ Design judgmentSpec on
Implemented-by: VERDICT: FAIL Generated by Claude Code |
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC
Requested by Leehom · project thread
Fixes #28952
Clause-②: no (narrowing)
Metadata-protocol: narrowed
维护者速读
upsert/update/ignore模式下,如果externalId(组合键的任一成员;没写时默认是name)不是目标对象的字段,保存、发布和恢复版本时都会被拒绝(422 INVALID_METADATA,和对象数据源写错时同一种拒绝格式,指出是哪个字段、哪个对象),什么都不写入。启动时加载种子从不拒绝,只在某个数据集的每条记录都取不到键值时打一行 warn。insert/replace模式不受影响。回滚即撤销本 PR。packages/metadata-protocol/src/protocol-save-door-refusals.ts(新增的seedExternalIdSaveRefusal,判断对象字段的逻辑在这里);packages/metadata-protocol/src/protocol.ts(保存入口和发布晋升里的两处调用);packages/metadata-protocol/src/seed-loader.ts(启动加载的那行 warn)。pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.seed-external-id-refusal.test.ts,预期 22/22 通过(main 上其中 12 条拒绝用例是红的,对照组是绿的)。What changes
The refusal. New
seedExternalIdSaveRefusalinpackages/metadata-protocol/src/protocol-save-door-refusals.ts, beside the other save-door refusals, with its class method inprotocol.ts.seedin a keyed mode (upsert,update,ignore). Each member ofexternalIdis checked; an undeclaredexternalIdisSeedSchema's default,name, the same default the loader applies (dataset.externalId || 'name').insertandreplacenever match by key and are not judged.request.pending.objects, the batch closurepublishPackageDraftsalready collects), else the engine's registered definition (engine.registry.getObject), whose fields include extension fields. The columns the registry injects count, through the spec'sresolveInjectedSystemColumns(soidand the audit and tenant columns are fields).unknownDatasourceSaveRefusal(metadata: an object bound to a datasource that does not exist publishes successfully, and is then silently unregistered (data calls 404 "not registered"); Studio still lists it #26724) answers:422 INVALID_METADATA, messageseed/NAME failed spec validation:plusspecValidationFindings(the REST faces carry the headline and the prose lives inissues[]), andissues[]with one finding per missing member atexternalId(externalId.Nfor a composite member),code: 'custom'. Each finding opens "Seed 'NAME' keys on externalId member 'FIELD', which is not a field of object 'OBJECT'" (or, undeclared, "declares no externalId, so it keys on the default 'name'"), then the consequence and the remedy, under the 500-character REST cap. No new error code.Where it is asked. The same three places
unknownDatasourceSaveRefusalis:saveMetaItem, draft and publish mode, after the schema and before the authoring gate and every write. Amigrate-storedre-save (the platform healing its own storage) is not judged.promoteDraftForPublish, on the draft body, beforerepo.promoteDraftwrites and before any seed row is loaded. It covers a seed draft stored before this change, one whose object was a draft at its save, and one whose object has since lost the field.publishPackageDraftsaborts the batch on it (outcome: 'refused'), andpublishMetaItemrefuses it.refuseRestoredBodyTheSaveDoorRefuses(rollbackMetaItem,revertCommit).The boot loader warns, never refuses. In
SeedLoaderService.loadDataset(seed-loader.ts), a keyed dataset whose every record has no value for its key logs onewarnper dataset, naming the object, the key and the mode. Its writes are unchanged. A replay of such a stored seed on this head still inserts every record again (upsert,ignore) or skips it (update); the loader test pins that, so the behaviour the doors now stop is stated rather than implied.Changeset
.changeset/28952-seed-external-id-refusal.md:minoron@objectstack/metadata-protocol, both declaration lines, the BREAKING banner and the ADR-0087not-required (no-migration-prescription)marker.Tests
examples/app-showcase,app-crm,app-todo,app-multi-package, the platform packages and hotcrm (d4ffe99) was imported and each keyed seed'sexternalIdchecked against its object's fields plus the injected columns. 47 keyed seeds (showcase 19, crm 5, todo 1, hotcrm 22; the platform packages and app-multi-package ship none), all with a declaredexternalId: 0 dangling, 0 relying on an implicitnamethe object lacks. Two targeted platform objects the scan could not load were read by hand:sys_business_unitkeyed onid(the primary key) and hotcrm'ssys_activitykeyed on['source_object', 'source_id'](both declared inplugin-audit).packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts(22 tests): the refusal at a draft save into a package, a draft save with no package and a publish save; each keyed mode; a composite with one bad member (externalId.1); an undeclared key on an object with noname;publishMetaItemandpublishPackageDraftson a seed draft stored before the rule (draft stays a draft, the batch promotes nothing, no seed row written); an object drafted in the same batch that lacks the field; the restore door. Controls:name,code,id, a composite of declared fields and an undeclared key on an object withnameeach save, publish (and load: the double records the seed row) and save live;insertandreplace; an object drafted in the same batch that declares the field; an object neither registered nor drafted; amigrate-storedre-save.packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts(5 tests): onewarnper load naming object and key,success: true, and a replay inserting the row again (2 rows); a composite member no record carries; controls: a declared key converging (inserted: 1, theninserted: 0, updated: 0) with no warn, aninsertdataset and a dataset where some record carries the key.protocol.restore-doors-save-refusal-family.test.ts: one morepinBothDoorscase, a seed version keyed on a field its object has since lost, refused atrollbackMetaItemandrevertCommitwith nothing stored; its controls restore the accepted version. The harness's registry gained anobjectsmap (empty for every other case).packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts(4 tests, the card's repro over the real showcase and REST): the draftPUT /api/v1/meta/seed/r_ghostrefused 422INVALID_METADATAatexternalIdwith nothing in_drafts; the composite refused atexternalId.1; control:externalId: 'name'publishesinserted: 1, republishesinserted: 0, updated: 0, andGET /api/v1/data/seedkey_otherserves oneG1; control: aninsertseed saves.62b3ec36. The three in-process files with the four source files restored to base: 16 failed, 47 passed, every red a refusal or warn pin and every control green. The dogfood case on a base build of@objectstack/metadata-protocol: 3 failed, 1 passed. Its control failed too,inserted: 2instead of1, because the ghost seed the base had accepted was published in the same batch: the card's duplication, measured.Ablation (one-off, from committed HEAD)
The save door's check in
saveMetaItemwas disabled withnode scripts/ablation-replace.mjs(if (seedKeyRefusal) throw seedKeyRefusal;→if (seedKeyRefusal) console.debug('ABLATION_28952_SAVE_DOOR');): anchor 1 → 0, blob96642a86ae58→18dc7463b075.@objectstack/metadata-protocolwas rebuilt andscripts/ablation-dist-preflight.mjsread the marker in 2 built files. Readings: the dogfood case 3 failed, 1 passed (the card and composite refusals, and the control, whose publish now carried the ghost seed and was refused by the publish promotion);protocol.seed-external-id-refusal.test.ts8 failed, 14 passed (the save-door cases; the publish-door cases stay green, as they should). Restored: blob equals HEAD96642a86ae58,git diff HEADempty; rebuilt, the preflight--absentread the marker in 0 of 24 built files with the tree clean; the dogfood case passed 4/4 again.A first attempt used
void 'ABLATION_28952_SAVE_DOOR'as the marker. The bundler drops that expression, so the preflight read it as absent from dist even though the dogfood case went red; it was redone with theconsole.debugmarker above, and only the second run's readings are quoted.Acceptance notes
400 VALIDATION_ERROR; the seat then ruled one door, one shape, so the refusal answers with the unknown-datasource refusal's422 INVALID_METADATAenvelope at all three sites (commit3d04cc7e).runBuildProbes,probes.checked.seeds) runs after the publish has written and by contract never fails it, so it cannot refuse "before any write". The publish-side refusal therefore sits inpromoteDraftForPublish, where every publish path promotes a draft and the sibling refusals already sit.build-probes.tsis unchanged.name. An undeclaredexternalIdis judged asname, because the loader keys onnameand the duplication is the same. The measurement found no shipped seed relying on it.object_extensiondrafted in the same package batch is not in the batch closure this reads, so a seed keyed on a field only that pending extension adds would be refused at the batch publish. No shipped seed does this; noted, not filed.warn. Under AGENTS.md's degradation rule the duplicate rows are a data-consistency outcome, but the doors now refuse such a seed at authoring and the boot must not fail, so the remaining case is a stored seed from before this change.Verification
Head
d9200288(base62b3ec36).origin/mainmoved 9 commits since the base, none touchingpackages/metadata-protocol,packages/qa/dogfoodor the double ledger; the branch was not merged with it, and the merge queue tests the merged tree.Package tests, typecheck, lint
pnpm --filter @objectstack/metadata-protocol test: 299 files passed (4 skipped), 22137 tests passed (26 skipped).pnpm --filter @objectstack/metadata-protocol --filter @objectstack/dogfood typecheck: exit 0 (the metadata-protocoltsconfigincludessrc/**/*, test files too).pnpm exec eslint --no-inline-config packages/metadata-protocol/src packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts: exit 0.Dogfood battery (a narrowing)
pnpm --filter @objectstack/dogfood testunder the verify lock, after the@objectstack/dogfood^...closure build: VERDICT command-exit 0, Test Files 384 passed (3 skipped), Tests 3167 passed (28 skipped), atb8d8083b. The one later commit,d9200288, changes onlyscripts/engine-double-contract.pinned.json, which no dogfood case reads.Derived gates
node scripts/pm/dispatch-gates.mjs --commandson this diff named 73 families (65 before the ledger commit, 8 more after it). All 73 exit 0, and--rananswers: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN.check:engine-double-contractexit 1 (the two new test doubles were not pinned;--write, committed ind9200288, then exit 0);check-plugin-teardown-shape --self-testexit 3 (shallow clone; the fixture commit fetched, then exit 0);check:dual-build-cjs-loadsexit 3 (prerequisite: not every package haddist/; after a fullturbo run build, 118/118 tasks, exit 0: 115 require entry points across 70 packages load).check:agent-test-spelling,check:bash32-floor,check:cli-command-ids,check:entry-guard,check:parse-guard,check:pnpm-filter-targets,check:pnpm-flag-spellings,check:scripts-symbol-anchors): exit 0 atd9200288.b8d8083b. CI runs the full farm on this head.Generated by Claude Code