Skip to content

finding: cloneData is the one create face whose response contract carries no droppedFields — a clone that copies or overrides a readonly column is stripped and warned, but not reported on the wire #15703

Closed

Description

@claude

Found while patching PR #15395 (the create-side static-readonly strip moving into engine.insert, card #14147); filed unassigned and out of that PR's scope because closing it changes a published response contract.

What is true today

After the 2026-09-03 ruling (option C on #14147), every DataProtocol create face hands the caller's payload to engine.insert whole and the engine strips a non-system caller's static-readonly keys. Five of the six create-shaped faces surface the engine's onFieldsDropped as their response droppedFields — createData, createManyData, insertManyData, and batchData's create rows and both arms of upsert that create. cloneData does not. It passes no listener, and its response contract, CloneDataResponseSchema (packages/spec/src/api/protocol.zod.ts, #11924, declared AS PRODUCED by maintainer ruling 2026-08-25), is exactly { object, id, sourceId, record }; packages/metadata-protocol/src/search-clone-schema-conformance.test.ts asserts in so many words that the body carries no droppedFields. PR #15395 pins that absence with its reason (protocol.readonly-insert.test.ts, the firing-control block) and narrows its changeset sentence to the faces that report.

Why it is worth a card

A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: cloneData copies the source row (approval_status: 'approved' included) and applies overrides on top, then inserts. The engine strips the copied or overridden value and logs the warn line — but the 201 body says nothing, so a caller that cloned an approved record and reads record.approval_status: 'draft' back has no field in the response telling it why. Every other create face now answers that question on the wire; the clone answers it only in server logs.

Suggested shape (a decision, not a patch)

Either add an optional droppedFields member to CloneDataResponseSchema (same shape as CreateDataResponseSchema's), wire the listener in cloneData exactly as createData does, and flip the two conformance pins and the firing-control pin — a @objectstack/spec surface widening, minor under the bump-level rule — or record that a clone deliberately does not report drops and say why on the schema. Both are legitimate; neither is a delegation detail of #14147.

Related: PR #15395, card #14147, the contract review at comment 5548671173 on #14147 (row A12).


Generated by Claude Code

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 5, 2026
  2. added
    enhancementNew feature or request
    pm:queueReady for the PM dispatch loop
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Sep 5, 2026
  3. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · pm:queue / domain:spec / priority:p2 / enhancement / finding

    ⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡。

    为什么锚定 domain:spec

    无论走哪条路,被改的契约字节都在 packages/spec/src/api/protocol.zod.ts 的 CloneDataResponseSchema 上(加一个可选 droppedFields 成员,或在 schema 上写明「clone 刻意不报告」)。按车道表:packages/spec 整包唯一契约、schema 形状与 describe 散文一律 domain:spec。

    ⚠️ 若裁定「加成员」,还需在 cloneData 上像 createData 那样接上 listener,并翻转两条一致性 pin 与 PR #15395 的 firing-control pin。⇒ 那些是同一次契约变更的必要随行,本席位按不可切分处理,⛔ 不拆卡。

    priority:p2 的理由

    要害是卡片点出的那条不对称性,而不是「少了一个字段」:

    clone 就是 create,而且它是唯一一种能在调用方没有键入任何东西的情况下携带 readonly 列的 create —— 它复制源行(approval_status: 'approved' 也一并复制),再把 overrides 叠上去,然后插入。

    ⇒ 引擎剥掉了那个值并记了 warn,但 201 的响应体一个字都没说。一个克隆了「已批准」记录、读回 record.approval_status: 'draft' 的调用方,在响应里找不到任何字段告诉它为什么。而其余五个 create 面(createData、createManyData、insertManyData、batchData 的 create 行与 upsert 的两条创建臂)都已经在 wire 上回答了这个问题。

    不给 p1:没有数据被写错——剥离是 2026-09-03 option C 裁决要求的正确行为,且 warn 已落到服务端日志。丢的是可观测性的一致性。
    不给 p3:6 个面里 5 个报告、1 个不报告,且不报告的恰是最容易在调用方无意识下触发的那一个。

    ⛔ 两条路线都合法 —— 本席位给排序,⛔ 不裁

    1. 加一个可选 droppedFields(与 CreateDataResponseSchema 同形),在 cloneData 里接上 listener,翻转两条一致性 pin 与那条 firing-control pin。⇒ @objectstack/spec 表面加宽,按 bump-level 规则取 minor。
    2. 记录 clone 刻意不报告 drop,并在 schema 上写明理由。

    本席位倾向 1,判据只有一条:「五个面回答、一个面沉默」这件事本身就是下一个人要重新发现的东西。若裁定 2,⛔ 请务必把理由写进 schema 的 describe/docblock,而不是只留在卡上——否则第三次发现它的人还会再开一张卡(本轮已经出现三张「解释缺失或过期导致重复发现」的卡:#15640、#15669、#15688)。

    ⚠️ 无论哪条:这条改的是已发布响应契约 ⇒ 按座位贴说明③,派发侧走条款②契约复审档位(needs:contract-review)。⛔ 本席位不代贴该标签、不代定档位。

    ⛔ 三条边界

    1. ⛔ 不是 finding: the insert-path readonly strip is a protocol-boundary guard only — engine.insert applies none of it, and create_record's onFieldsDropped channel can never fire for a readonly drop #14147 的委派细节。 卡片明说,且 PR fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted #15395 刻意把它留在范围外并用 protocol.readonly-insert.test.ts 的 firing-control 块钉住了这处缺席及其理由、把 changeset 措辞收窄到「会报告的那些面」。⇒ 本卡不得被读成 finding: the insert-path readonly strip is a protocol-boundary guard only — engine.insert applies none of it, and create_record's onFieldsDropped channel can never fire for a readonly drop #14147 的收尾。
    2. ⛔ 不要绕过 CloneDataResponseSchema 的「AS PRODUCED」性质。 该 schema 按 2026-08-25 维护者裁决是按实际产出声明的(Four client SDK routes answer a shape no published contract declares — automation.create / automation.update / search / data.clone #11924)⇒ 加成员必须与 cloneData 的真实产出同批变更,⛔ 不能先声明后实现。
    3. 两条 pin 是判据不是障碍:packages/metadata-protocol/src/search-clone-schema-conformance.test.ts 逐字断言该 body 不带 droppedFields。⇒ 走路线 1 就得翻转它;⛔ 但翻转前请确认 PR fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted #15395 已落地,否则会与它的 firing-control 块打架。

    Refs:PR #15395 · #14147(2026-09-03 option C 裁决)· #11924(CloneDataResponseSchema 的 AS PRODUCED 裁决)· #14147 上的契约复审 5548671173(row A12)。

    分诊席位 · claude-opus-5 · 本轮 R+156


    Generated by Claude Code

  4. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 8, 2026
  5. zhuangjianguo commented on Sep 8, 2026

    @zhuangjianguo
    Collaborator

    Half-state repaired — the label write the 14:22Z conversion declared but never landed

    domain:spec execution seat, session session_016N6xmWt5hYm94ffVEwGH8x, R1, 2026-09-08T02:38Z.

    Comment 5572013366 (2026-09-07T14:22Z) declared 「pm:queue → needs-user-decision」 and carried the full 四棱 + 维护者速读. The comment landed; the label write did not. The card has been sitting in pm:queue since — i.e. visible to this lane's candidate selection as dispatchable work, while its own newest comment says every route needs a maintainer ruling.

    ⇒ ⛔ This is not a re-adjudication and ⛔ not a new judgment. It is 补齐成对 on a state change that went out half-written: one label write, pm:queue → needs-user-decision, read back (enhancement, priority:p2, needs-user-decision, domain:spec, finding; assignee empty). The analysis, options and recommendation of 5572013366 stand unchanged and untouched.

    How it was caught. Taking over this seat, the outgoing brief (5577721567) listed five cards as 决策盒待裁. Reading each against its own thread rather than against the brief showed the list was stale in both directions: #14791 (batch #1) and #15543 (batch #2) were ruled on 2026-09-07 and correctly converted to pm:queue, and #16685 was ruled 2026-09-08T00:17Z (batch #80) — three dispatchable cards, not pending ones. This card is the only one of the five that is genuinely still awaiting a ruling, and it was the one card wearing the wrong label.

    ⚠️ Still open for the maintainer, unchanged: 选 1(给 CloneDataResponseSchema 加可选 droppedFields)还是 2(写明 clone 刻意不报告,理由落进 schema 注释)? The four facets are not aligned on this card — three point to 1, 「创业阶段不扩散」 points to 2 — which is precisely why it is here and not auto-adjudicated.

    ⛔ Not dispatched. ⛔ No grade, type or domain:* touched.


    Generated by Claude Code

  6. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Ruling recorded — option 1: cloneData reports droppedFields like every other create face (director seat, decision batch #83, 2026-09-08)

    Maintainer's reply, verbatim (live PM chat, 2026-09-08T03:4xZ, answering the batch #83 presentation where this card was item 1 with recommendation 1):

    其他同意

    ⇒ CloneDataResponseSchema gains an optional droppedFields member of the same shape as CreateDataResponseSchema's; cloneData passes the onFieldsDropped listener exactly as createData does; the two conformance pins (search-clone-schema-conformance.test.ts) and PR #15395's firing-control pin in protocol.readonly-insert.test.ts flip in the same PR. @objectstack/spec changeset minor under the bump-level rule.

    Sequencing: the schema member and the producer land together ("declared AS PRODUCED" — never a declared member the route does not fill). The PR is Clause-②: yes (a published response contract widens) and goes through the contract-review tier.

    Label: needs-user-decision → pm:queue. ⛔ Not dispatched by this seat.


    Generated by Claude Code

  7. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 8, 2026
  8. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 8, 2026
  9. self-assigned this
    on Sep 8, 2026
  10. zhuangjianguo commented on Sep 8, 2026

    @zhuangjianguo
    Collaborator

    Claim: session session_016N6xmWt5hYm94ffVEwGH8x · branch claude/issue-15703-clonedata-dropped-fields · domain:spec execution seat · 2026-09-08T06:22Z

    Dispatching the ruled work to an os-dev subagent. The dev inherits this claim: it does not write the assignee and does not post a second Claim:.

    Ruling being executed — comment 5578819560 (director seat, decision batch #83, 2026-09-08), maintainer verbatim 「其他同意」, option 1: CloneDataResponseSchema gains an optional droppedFields member of the same shape as CreateDataResponseSchema's; cloneData passes the onFieldsDropped listener exactly as createData does; the two conformance pins and PR #15395's firing-control pin flip in the same PR. ⛔ Not re-adjudicated.

    Clause-②: yes — this widens a published response contract, so the dispatch runs at CONTRACT_REVIEW_TIER, not the default tier, and ⛔ the PR cannot be enqueued on a below-tier verdict.

    File-face declared (for cross-lane intersection checks): packages/spec/src/api/protocol.zod.ts, packages/metadata-protocol/src/search-clone-schema-conformance.test.ts, the cloneData implementation and its protocol.readonly-insert.test.ts firing-control block, plus a .changeset/ entry. ⚠️ This intersects packages/spec with no other in-flight card in this lane at claim time.

    Sequencing precondition verified before dispatch, not assumed: PR #15395 is MERGED (merged_at: 2026-09-05T12:24:23Z), so the firing-control pin it added is on main and can be flipped without fighting it — this was the condition triage attached to route 1.

    https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x


    Generated by Claude Code

  11. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 8, 2026
  12. claude commented on Sep 8, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 15703,
      "status": "done",
      "branch": "claude/issue-15703-clonedata-dropped-fields",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16823",
      "premise_still_valid": true,
      "summary": "Option 1 implemented as ruled (comment 5578819560): CloneDataResponseSchema gains an optional droppedFields member with the same Zod shape as CreateDataResponseSchema's (z.array(DroppedFieldsEventSchema).optional()); cloneData wires the onFieldsDropped listener byte-for-byte as createData does and spreads droppedFields onto its return only when non-empty; the two conformance pins in search-clone-schema-conformance.test.ts and PR #15395's firing-control pin in protocol.readonly-insert.test.ts flip in the same PR; changeset @objectstack/spec minor (plus @objectstack/metadata-protocol minor and @objectstack/client minor). AS PRODUCED honoured: the conformance suite now measures the produced member on the real producer over an engine double that strips and fires the event. All six ZONE 2 premises held, with one nuance for the reviewer: the card's 'in so many words' forbid (conformance test line 227, expect(body).not.toHaveProperty('droppedFields')) sat on a fixture engine that never fired onFieldsDropped, so it was vacuous with respect to the listener — the teeth against wiring were protocol.readonly-insert.test.ts:266 and packages/objectql/src/protocol-data.test.ts:654 (the latter unnamed on the card; it asserted insert was called with exactly { context }), both flipped. Beyond the three ruled files, two edits flagged in the PR body: packages/client/src/index.ts CloneDataResult (the declared mirror) gains the same optional member because its published docblock stated 'No droppedFields' — a sentence this change falsifies in a shipped .d.ts; and content/docs/api/data-api.mdx (coordinator's docs-drift reading) stated the 201 body as exactly { object, id, sourceId, record } — changed; data-engine.mdx and http-protocol.mdx measured still true and left; neither release-owned page is falsified (no docs-only card needed); none of the five pages names CloneDataResult at all (0 hits each, control page 4 hits of CloneDataResponse). The CreateDataResponseSchema describe sentence about the X-ObjectStack-Dropped-Fields header was NOT copied: measured, the clone route relays verbatim and never calls applyDroppedFieldsHeader, so the new describe says body-only. Process facts: claim verified (5580290466 names this branch; assignee was already set by the PM, untouched); origin/main merged before opening the PR via scripts/pm/os-regen-merge.sh after dispatch-gates reported a STALE TREE (8 gate scripts moved upstream), hand-written delta byte-identical across the merge, check:generated green post-merge with nothing to regenerate; a second STALE TREE at the final head names only scripts/check-regen-pending.mjs and scripts/git-merge-regen.mjs (merge-driver tooling, not a family my paths derive a verdict from) — not merged again, CI runs current copies. Label writes, both additive and read back: needs:contract-review on PR #16823, and — after check-clause2-carriers --pair 16823 answered exit 4 (C1: PR carries it, card does not) — the same label on card #15703, which the triage seat had explicitly left to the dispatch side; the predicate then answers exit 0. The run was killed by a 429 mid-task and resumed from re-established state (two commits already pushed); nothing was redone. Worktree removed after the PR opened. PR left a draft; not readied, enqueued or armed.",
      "tests": "All build/test/typecheck through scripts/pm/os-verify-lock.sh (slot issue-15703), exit codes captured before any pipe; VERDICT lines quoted. Heads: b617ce886 (pre-merge), 68c74383f (after merging origin/main), d11785752 (final, docs-only commit on top; hand-written code delta identical across all three by diff-of-diffs cmp). spec build: VERDICT command-exit 0 (204s at b617ce886; 209s at 68c74383f); rebuilt dist/api/index.d.ts carries droppedFields inside CloneDataResponseSchema (positive control 15 hits in the file). spec check:generated: pre-merge 1 of 15 stale (content/docs/references → gen:docs) regenerated via --fix and committed (authorable-surface/api.json written by the build's gen:schema; authorable-surface.base.json untouched); post-merge '✓ All 15 generated artifacts are up to date.' Closure: turbo build objectql^... + metadata-protocol (14/14) — metadata-protocol/dist/index.js carries the spread droppedFields at the clone return. metadata-protocol pnpm test @b617ce886: 'Test Files 169 passed | 2 skipped (171)', 'Tests 2434 passed | 10 skipped (2444)'; typecheck (tsc --noEmit) 0 errors. The two flipped files explicitly @68c74383f: 'Test Files 2 passed (2)', 'Tests 19 passed (19)'. objectql src/protocol-data.test.ts + src/protocol-clone-real-engine.test.ts @b617ce886 and @68c74383f: 'Test Files 2 passed (2)', 'Tests 121 passed (121)'; objectql check:test-typecheck OK (44 files / 242 errors / 69 pinned — the ledgered debt, unchanged). client tsc --noEmit (src): exit 0, empty, both heads. spec pnpm test @b617ce886 and @68c74383f: 'Test Files 465 passed (465)', 'Tests 12955 passed (12955)'; spec typecheck exit 0 incl. check:test-typecheck OK (54 files / 261 errors / 145 pinned, unchanged). ABLATION (under the lock, VERDICT command-exit 0 · held 58s, trap-restored, absolute paths): cloneData's opts listener replaced by a marker comment — on-disk proof grep -c marker=1, neighbouring opts.context line=1, the 3 identical listener spellings in other faces untouched; leg A (src-resolved) vitest run of the two metadata-protocol files: exit 1, 'Tests 4 failed | 15 passed (19)' — the clone delegation case (expected undefined to deeply equal the event), the 7-face enumeration ('expected undefined to be function'), the named clone presence pin, and the conformance 'declared AND produced' case; leg B (dist-resolved: objectql imports @objectstack/metadata-protocol unaliased) rebuild exit 0 → ablation-dist-preflight marker present exit 0 → protocol-data.test.ts exit 1, 'Tests 1 failed | 116 passed (117)' at 'forwards context to findOne and insert'; restore: git checkout HEAD -- ABS_PATH, git diff HEAD 0 lines, hash-object equals HEAD blob 67bcd138cc046aaf053bbe61ad23956b70e80a52, marker 0, status clean; rebuild exit 0, preflight --absent exit 0 ('marker absent from all 24 built files'), both suites green again (19/19, 117/117). Direction observed: red, no reversal. GATES: dispatch-gates --commands --repo objectstack-ai/objectstack at the merged head derived 103 families (first derivation at b617ce886 was 80: the committed docs regen added 23 docs/prose families and a STALE TREE named 8 moved gate scripts — hence the merge); all 103 run, --ran reconciles '103 derived, 103 run, 0 UNRUN' at 68c74383f and again at d11785752; 101 exit 0 incl. check:nul-bytes, check:empty-changeset, check:adr-0087-registration, check:changeset-no-major, check:pm-widening-tells, check:engine-double-contract, check:corpus-claim-drift, check:doc-anchors, spec check:api-surface / check:authorable-surface / check:docs / check:liveness / check:skill-examples (the last only after client + client-react were built; its first run was a build-prerequisite refusal). After the docs-only commit the 25 docs-reading families were re-run on that exact tree: 21 exit 0 immediately, 4 answered exit 3 because the re-created worktree had no dist (check:doc-formula-expressions, check:doc-security-posture, check:docs, check:docs-transcript-drift) — spec + lint rebuilt under the lock (VERDICT command-exit 0 · held 159s) and all 4 then exit 0 before the commit was made. NOT MEASURED, declared to CI: @objectstack/client full pnpm test and its check:test-typecheck (unit tests import @objectstack/runtime / objectql / plugin-hono-server unaliased, outside the built closure; the client change is a type-only widening); pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt (both PREREQUISITE NOT MET, exit 3, 57 packages without dist); check:react-declaration-parity (EXTERNAL_INPUT_REQUIRED, as always). check-clause2-carriers --pair 16823: exit 4 before the card label (C1 split), exit 0 after. PR body read back twice: create stored byte-identical minus one trailing newline; the PATCH (sent without a footer) stored byte-identical with the platform's bare footer block appended; 0 angle brackets, no HTML comment. CI status at report time: in_progress (not waited on).",
      "mcp_calls": "0 — every GitHub read and write went over repo-scoped REST (issue + comments + PR #15395 reads; branch-push write probe; POST pulls draft=true; POST issues/16823/labels; POST issues/15703/labels; PATCH pulls/16823; GET read-backs after each write; POST issues/15703/comments for this report). No search_issues; dup check not needed (this is a ruled card, no new issue filed).",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: after this PR the clone 201 body carries droppedFields but the route sets no X-ObjectStack-Dropped-Fields header, while POST /data/:object and PATCH /data/:object/:id set both (bulk routes set neither) — incompleteness, not a defect class, and no contract text promises the clone route a header; deliberately not added (a wire change the ruling did not name); the schema describe and data-api.mdx state body-only. 承接者: this PR's contract reviewer if they want it; otherwise none.",
        "noted, not filed: .changeset/tidy-cups-smile.md (PR #15395, unreleased) still says cloneData 'forwards whole but reports nothing on the wire … adding that key is a spec change' — accurate history of that change, left untouched; this PR's changeset states the new fact. 承接者: whoever compiles the release notes.",
        "noted, not filed: docs/qa/platform-checklist/areas/records-forms.json 'Clone contract edges' item describes the body as {object, id, sourceId, record} (still true for a no-drop clone); its readonly-forge step could now also assert droppedFields. 承接者: the next checklist-author sweep.",
        "noted, not filed: content/docs/kernel/contracts/data-engine.mdx layering callout's hedge 'wherever their contract declares one' is now vacuous (all six create faces declare it) — still true, polish only. 承接者: none.",
        "noted, not filed: content/docs/releases/implementation-status.mdx and content/docs/releases/v17.mdx (release-owned, read-only) are NOT falsified by this change — implementation-status lists the clone route as implemented, v17's write-observability note claims droppedFields for PATCH/POST /data, bulk, batch and the SDK and never claimed or denied the clone; no docs-only card needed.",
        "noted, not filed: dispatch-gates STALE TREE at the final head names scripts/check-regen-pending.mjs and scripts/git-merge-regen.mjs (2 upstream commits after the merge) — merge-driver/pre-commit tooling, not a family whose verdict my paths move; CI runs the current copies. 承接者: none."
      ]
    }

    Generated by Claude Code

  13. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 8, 2026
  14. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:specDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestfindingRecorded observation — held, not dispatchable until the findings triage round grades itpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions