Skip to content

metadata-protocol: an upsert seed whose externalId names a field the object lacks is accepted and inserts a duplicate row on every publish, silently #28952

Closed

Description

@objectstack-fleet

domain:devx#1 · os-warren · session_01JQwxbWBtYWqaV8NhvUrShB

Filed by the dispatching PM seat session for the W4-C3a runner session_01JQwxbZsvATYMNMArStUo2M, whose own write was refused by its session permissions (RUNNER rule 2: staged text, posted verbatim through the same door).

立卡门类别: ① 产品缺陷 — 具名落点 (SeedLoaderService.loadExistingRecords, packages/metadata-protocol/src/seed-loader.ts; the seed save / publish probe) + 复现
reach: 公开入口实测一次错误结果 — PUT /api/v1/meta/seed/... with externalId naming no field is saved valid:true, and each POST /api/v1/packages/ID/publish-drafts inserts a duplicate row (two independent boots at 7fa9873)
业务理由: a package author who mistypes the upsert key (or renames the field it names) gets a duplicate of every seeded row on every publish, with a success receipt and no warning. Users then see doubled reference data. Not doing it: silent data duplication on each deploy, discovered only by end users.
动手的读者: the domain:server seat (metadata-protocol seed loader and publish probe); triage first touch confirms the lane, then os-dev.
查重: seed externalId → 46 hits, 0 open (#26674, #24624, #24562 closed, different seams); seed-loader externalIdKey → 3 hits, 0 open. No duplicate.

QA-source: #28942 · platform-core.seed-mode-matrix · observed beside the clauses

What happens. A seed dataset with mode: 'upsert' and an externalId that is not a field of its object is accepted at save (it reads back _diagnostics: {valid: true}) and at publish. Every POST /api/v1/packages/ID/publish-drafts of the unchanged seed then answers seedApplied {"success":true,"inserted":1,"updated":0} with no probe issue and no log line, and the table gains one identical row per publish. The upsert has silently become insert — the replay duplication the build-time lint seed-insert-mode-duplicates-on-replay (validate-seed-replay-safety.ts, framework#3434) exists to flag, with no warning here because the mode reads upsert.

Mechanism. SeedLoaderService.loadExistingRecords (packages/metadata-protocol/src/seed-loader.ts) keys each existing row by externalIdKey(record, externalId), and an empty key is never registered. With a field the object lacks, every existing and incoming key is empty, so decideWriteAction finds no match and returns insert. Nothing between the save door and the loader checks SeedSchema.externalId against the object's declared fields, and neither does the publish probe pass (probes.checked.seeds).

Reproduction (fresh showcase boot at 7fa9873, admin bearer; reproduced on two independent boots and DBs):

  1. POST /api/v1/packages {"manifest":{"id":"com.objectstack.qa.seedrepro","name":"R","version":"1.0.0","scope":"project","type":"app","engines":{"protocol":"^18"}}} → 201.
  2. PUT /api/v1/meta/object/seedrepro_other?mode=draft&package=com.objectstack.qa.seedrepro {"name":"seedrepro_other","label":"R","sharingModel":"public_read_write","fields":{"name":{"type":"text","label":"Name"},"code":{"type":"text","label":"Code"}}} → 200, then POST /api/v1/packages/com.objectstack.qa.seedrepro/publish-drafts {} → published.
  3. Three times: PUT /api/v1/meta/seed/r_ghost?mode=draft&package=com.objectstack.qa.seedrepro {"object":"seedrepro_other","mode":"upsert","externalId":"no_such_field","records":[{"name":"G1","code":"g"}]} → 200, then POST …/publish-drafts {}.
  4. GET /api/v1/data/seedrepro_other.
  • Expected: the save or the publish refuses (or at least diagnoses) an externalId naming no field of the seed's object; failing that, replays converge on one G1 row.
  • Actual: each publish answers inserted:1 with 0 probe issues; step 4 returns 3 rows named G1 with three ids.
  • Control: the same seed with externalId: "name" → inserted:1, then inserted:0, updated:0 on replay.

Fix direction: refuse or diagnose at the seed save / publish probe when externalId (each member, for a composite) is not a declared field of the seed's object; the loader could also warn when every key of a keyed dataset evaluates empty.

Full evidence: QA run record #28942.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: platform-core.seed-mode-matrix

    Triage: priority:p2 · domain:server · pm:queue · 2026-11-02T05:00Z

    ⛔ Not a claim.

    • Read fresh. The fix lands in SeedLoaderService.loadExistingRecords (packages/metadata-protocol/src/seed-loader.ts) and the seed save / publish probe, so it is domain:server. An upsert seed whose externalId names no declared field silently turns into insert and duplicates rows on every publish, which is a user-visible behaviour defect, so it is p2 under 「协议新增定 p1,其余 p2」. The location, repro and control are named and nothing needs the maintainer, so it goes to pm:queue.

    Generated by Claude Code

  2. added
    area:devpathThe road — create, dev, verify, publish/install, connect an agent, iterate
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 2, 2026
  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

    Claim: PM loop round 1 · card thread · 2026-11-02T05:20Z
    Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat); the card thread is session_01FEkQ5pEjzdJdM36Fgh7YyY (a new card thread, fresh from main)
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-ncjmkt (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#1
    Ruling-ref: triage#1 graded the card p2 (05:00Z). Route (the dispatching seat's call): the seed save door and the publish probe refuse an externalId (each member of a composite) that names no field of the seed's object; the boot loader never refuses and names the dataset once at warn when every key of a keyed dataset is empty. Dispatched in the seat-1 split of the server queue under 「队列进了很多任务,抓紧派发」 (Leehom, 2026-11-02T05:15Z) and 「定级你可以自己决定」 (Leehom, 2026-11-01T12:11Z).
    File surface: the seed save door and the publish probe in packages/metadata-protocol, and SeedLoaderService (packages/metadata-protocol/src/seed-loader.ts); their tests and changesets. ⛔ No packages/spec/**, no new error code, no new gate.
    Container & model: S, mode:thread, model and effort as the thread was opened
    Clause-②: no (narrowing) (expected: a seed save or publish that was accepted with a dangling externalId is refused; the dev measures)
    Metadata-protocol: per AGENTS.md, measured by the dev
    Responsibility: the code is the seed save, publish probe and loader of @objectstack/metadata-protocol · the platform path is an upsert seed whose key names no field, inserting a duplicate row on every publish · reached by every package author who mistypes or renames the upsert key
    Thread-read: none
    Premise: holds on main 7fa9873f (the card's two boots)
    Serial constraints cleared: No open server card edits seed-loader.ts or the seed save door.
    Resume-from: none — fresh from origin/main

    State: pm:queue → pm:dispatched, assigned to os-project-manager, recorded by this claim.


    Generated by Claude Code

  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-project-manager · session_01FEkQ5pEjzdJdM36Fgh7YyY

    os-dev-report
    {
    "issue": 28952,
    "status": "done",
    "branch": "claude/project-thread-ncjmkt",
    "pr": "#29066",
    "session": "session_01FEkQ5pEjzdJdM36Fgh7YyY",
    "premise_still_valid": true,
    "summary": "Step 1 measured 0 dangling externalIds over 47 keyed seeds (showcase 19, crm 5, todo 1, hotcrm d4ffe99 22; platform packages and app-multi-package ship none). New seedExternalIdSaveRefusal (protocol-save-door-refusals.ts) refuses a seed in upsert/update/ignore mode whose externalId member (undeclared = default name) is not a field of its object, 400 VALIDATION_ERROR with one issue per member at externalId / externalId.N, asked in saveMetaItem (draft and publish; migrate-stored exempt), promoteDraftForPublish (batch object drafts judged from request.pending.objects) and the restore doors. Object unresolvable at save (still a draft) is left for its publish; neither registered nor drafted is not judged. SeedLoaderService warns once per keyed dataset whose every record keys empty and loads as before: a replay of such a stored seed on this head still inserts every record again (upsert, ignore) or skips it (update). Changeset minor on metadata-protocol, Clause-2 no (narrowing), Metadata-protocol narrowed.",
    "tests": "metadata-protocol full suite at b8d8083: 299 files / 22137 tests passed. New protocol.seed-external-id-refusal.test.ts 22/22, seed-loader-empty-key-warn.test.ts 5/5, restore-doors family 36/36 (2 new refusal pins + 2 controls), dogfood seed-external-id-refusal 4/4. Red on base 62b3ec3: in-process 16 failed / 47 passed (all reds refusal or warn pins); dogfood on a base build 3 failed / 1 passed (control inserted 2, the card's duplication). Ablation of the saveMetaItem check via ablation-replace (blob 96642a86ae58 to 18dc7463b075), rebuilt, preflight marker in 2 dist files: dogfood 3 failed / 1 passed, unit 8 failed / 14 passed; restored (blob == HEAD, git diff HEAD empty), rebuilt, preflight --absent 0 of 24 files, tree clean, dogfood 4/4. First ablation attempt used a void-literal marker the bundler dropped; redone, only the second quoted.",
    "gates": "dispatch-gates --commands on the final diff: 73 families, all exit 0; --ran: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN. First pass: check:engine-double-contract exit 1 (new doubles unpinned; --write committed d920028, then 0), check-plugin-teardown-shape --self-test exit 3 (shallow; fixture fetched, then 0), check:dual-build-cjs-loads exit 3 (prereq; full turbo build 118/118, then 0). 62 families ran at b8d8083, the 11 re-run/new at d920028. Typecheck metadata-protocol + dogfood exit 0; eslint scoped exit 0. CI check-runs: in_progress at report time.",
    "dogfood_battery": "b8d8083b · VERDICT command-exit 0 · Test Files 384/384 passed (3 skipped) · Tests 3167 passed (28 skipped) · no failures. d920028 changes only scripts/engine-double-contract.pinned.json.",
    "mcp_calls": "0",
    "api_writes": "3 — git push (not REST); relay pr_create #29066; relay os-dev-report comment on #28952",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none · noted, not filed: an object_extension drafted in the same package batch is not in the batch closure, so a seed keyed on a field only that pending extension adds would be refused at batch publish (no shipped seed does); sibling unknown-datasource refusal answers 422 INVALID_METADATA while the order fixed 400 VALIDATION_ERROR here; branch not merged with origin/main (9 commits, none in touched packages)"
    ]
    }

  5. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

    Release: server seat 1 shift close · 2026-11-02T07:43Z
    Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat that claimed this card); card thread session_01FEkQ5pEjzdJdM36Fgh7YyY (paused)
    Who / what / where: Leehom (os-project-manager) in the server-lane project chat — 「进展中的开发先提交代码然后暂停,先确保已有的pr都合并」 (2026-11-02T07:11Z), 「自动合并中的也交给下一任跟进」 (2026-11-02T07:23Z), 「我说的是pm-dispatch 中车道下班的流程,都要走完」 (2026-11-02T07:33Z). Shift close returns every claim to the queue (seat-post-protocol.md 交接收尾清单 step 2); no stay-behind.
    Resume-from: claude/project-thread-ncjmkt@9127f436332169631f997380f1a74079598e612f — PR #29066, ACCEPTed by the seat on this head. ACCEPT 6571560317 (after the fixture rename to sku); auto-merge armed, checks pending.
    Successor: read the handoff 6571543819 on seat post #6367 first; land PR #29066 on green, write the landing record, close this card. ⛔ Do not re-dispatch the card from scratch.

    State: pm:dispatched → pm:queue, assignee os-project-manager cleared, recorded by this release.


    Generated by Claude Code

  6. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

    Claim: PM loop round 1 · takeover of a released card · 2026-11-02T08:18Z
    Session: session_01ADHkmQjPiXgNoSaEpqeGvY
    Account: os-steve (the card's assignee, set in the same round)
    Branch: claude/project-thread-ncjmkt
    Worktree: the predecessor card thread's (paused); no new dev is dispatched
    Domain: domain:server
    Seat: domain:server#1
    File surface: unchanged from the predecessor's claim 6569727725 (seed save door, publish probe, packages/metadata-protocol/src/seed-loader.ts); the open PR #29066 is the surface.
    Container & model: S, mode:thread, landing only (no new build)
    Clause-②: no (narrowing)
    Metadata-protocol: narrowed
    Responsibility: as in the predecessor's claim (unchanged)
    Thread-read: 6571815096
    Premise: holds (PR #29066 ACCEPTed by the predecessor seat)
    Serial constraints cleared: none new; the seven PRs this seat inherited are its only in-flight surface.
    Resume-from: claude/project-thread-ncjmkt@9127f436332169631f997380f1a74079598e612f

    Takeover record: the predecessor (os-project-manager, session_01R1q2xdtYgU6FKrZ7LgjPCC) released this card in 6571815096 at its shift close (Leehom: 「自动合并中的也交给下一任跟进」). This seat takes it on the maintainer's summons in the Claude Code project "objectstack spec" (「server 1 号座位你也接手开席位」), seat 2 (#20966) having left it to seat 1. Last pushed head: 9127f43633 (PR #29066). In the merge queue since 2026-11-02T07:50Z; follow to MERGED.

  7. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

    Landed · 2026-11-02T09:25Z

    Probe Reading
    PR #29066 MERGED 2026-11-02T09:18Z, merge commit 3cf7f61e9f, through the merge queue (queued by the predecessor seat)
    #28952 closed completed by Fixes #28952

    pm:dispatched is removed. Seat records: review FAIL 6571500081 (one CI red on a fixture column), fixture rename at 9127f436, ACCEPT 6571560317 (predecessor); this seat took the card over to land it.

    • The triage seat claimed a contract review of the landed head 9127f436 (claim 6573033881 on the PR). If it FAILs, the finding goes to a follow-up card; the closed PR is not reopened. Still owed from the predecessor review: a seed keyed on a field only a pending object_extension draft adds is refused (should-fix, not filed yet).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:devpathThe road — create, dev, verify, publish/install, connect an agent, iteratedomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions