Skip to content

metadata-protocol: duplicatePackage silently merges a copied permission set's objects keys when a renamed key meets one the set already holds: one grant is dropped, and the copy can gain edit and delete the source never granted #23537

Closed

Description

@objectstack-fleet

Filing gate ①, finding class (a): a reproducible defect, measured on a real kernel. Filed by domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy, from #23527's dev report (PR #23536, out-of-scope finding 2). ⛔ Not a claim. Grade and lane are triage's.

reach: the named producer ObjectStackProtocolImplementation.duplicatePackage (the method the packages duplicate route calls), on a real kernel (ObjectKernel + ObjectQLPlugin, memory driver) at PR #23536's head ee862ff9d. The HTTP door was not driven.

Reader who acts: triage, to grade and route. The fix lands in duplicatePackage's record-key rule (deepRewrite, packages/metadata-protocol/src/protocol.ts), which PR #23525 (#23511) added. PR #23536 (#23527) refuses the same collision on a copied set's fields keys ("refused, never merged"), so this fix can follow that shape. It touches the same region, so it lands after PR #23536.

What happens (measured)

The source set is saved through protocol.saveMetaItem (success: true) while no object named dupdst_customer exists:

  • objects: { dupsrc_customer: { allowRead: true }, dupdst_customer: { allowRead: true, allowEdit: true, allowDelete: true } }.

Duplicating the package into namespace dupdst answers failed: [], and the copied set holds:

  • objects: { dupdst_customer: { allowRead: true, allowEdit: true, allowDelete: true } }.

The renamed key dupsrc_customer → dupdst_customer meets the key the set already holds, and the record-key rule keeps whichever entry comes last in insertion order. One grant is dropped with no diagnostic. In this case the copied object gains edit and delete, which the source set never granted on its own object.

The contract

What done looks like

  • A copied set whose renamed objects key equals a key it already holds is listed in failed[], naming both keys and the source edit that clears it. Nothing is written for it.
  • A set with no such collision copies as today.
  • Pin it on a real kernel, with a negative control: the same set without the second key copies, and its one grant follows the renamed object.

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included: duplicatePackage permission set objects key collision silent overwrite grant widened record key rename merge drops grant → 14 hits. The first 10 are listed here:

None of them is a key collision inside one copied set.

Dedupe words: duplicatePackage objects key collision silent overwrite; copied permission set grant widened; record key rename merge drops grant.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · security · priority:p2 · domain:spec · pm:queue · land after PR #23536 · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T20:53Z

    Read fresh. duplicatePackage's record-key rule keeps the last entry when a renamed objects key meets one the copied set already holds. One grant is dropped with no diagnostic. In the measured case, the copy gains edit and delete on its object, which the source never granted on its own object.

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (#23537: duplicatePackage's record-key rule refuses a copied item whose renamed object-reference key meets a key it already holds, listing it in failed[] with both keys, never merging. The first case is a permission set's objects, following PR #23536's "refused, never merged" shape for fields keys. Pinned on a real kernel; triage 6178230551, landing after PR #23536, which merged as 7b64a3d73) · 2026-10-14T21:13Z
    Session: session_01YRp5z4kK6vgYkrdH97ztsy
    Account: huangyiirene (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23537-duplicate-objects-key-collision
    Worktree: objectstack-issue-23537
    Domain: domain:spec
    Seat: domain:spec#1 (seat post #6017)
    File surface (at origin/main 7b64a3d73 or later; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 23537,
      "status": "done",
      "branch": "claude/issue-23537-duplicate-objects-key-collision",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23545",
      "session": "session_01YRp5z4kK6vgYkrdH97ztsy (subagent run: the dispatching PM session's id, as both commits' Claude-Session trailer carries it)",
      "premise_still_valid": true,
      "summary": "duplicatePackage's deepRewrite now records every meeting at an object-reference site (a renamed record KEY landing on a key the record already holds, or a renamed array ENTRY landing on an entry the array already holds when the two source entries differ) in a per-item collisions list, and the one call site refuses the item in failed[] before saveMetaItem, so nothing of it is written (no item, history, commit or audit row). The refusal names the item, the property, the name that moved, the name it met and the source edit ('Remove A or B on the source, then duplicate again.'). H1 measured at base 7b64a3d73: a set holding dupsrc_customer and dupdst_customer copied with failed: [] keeping the last entry (wide grant kept, or the wide grant dropped in the other order); H2: the same walker also merged translation.objects (a label dropped) and hook.object entries (copied as ['dupdst_customer','dupdst_customer'], handler measured running twice per insert through bindHooksToEngine), and the fix covers all five object-reference properties at once; the other sites are string-valued and cannot collide. Changes: protocol.ts duplicatePackage region only (+72/-4: docblock sentence, deepRewrite, describeCollision, call site), one real-kernel pin file (5 cases), one patch changeset.",
      "tests": "At HEAD 1b8e8e13b. New pin packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts (ObjectKernel + ObjectQLPlugin, memory driver; reads stored rows from the driver): permission objects collision refused in both key orders (located refusal; no row in sys_metadata/_history/_commit/_audit mentions dupdst_objs, while the copied object is found in sys_metadata and sys_metadata_history as the scan's control; source untouched); NEGATIVE CONTROL same set without the second key copies as { dupdst_customer: { allowRead: true } }; translation objects collision refused; hook object-entry collision refused plus NEGATIVE CONTROL single-entry hook copies as ['dupdst2_customer']. Base (probe, uncommitted, deleted): failed: [] in all three collision shapes. Suites: pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 -> 236 files passed, 3 skipped; 28409 tests passed, 19 skipped (lock VERDICT command-exit 0). pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 -> 419 files, 8113 tests passed (VERDICT command-exit 0). metadata-protocol typecheck exit 0; objectql typecheck exit 0 with check:test-typecheck OK, debt unchanged (40 files / 234 errors / 65 signatures); the new file is in tsconfig.test.json's program (--listFilesOnly: 1 hit). eslint --no-inline-config on the two changed source files: exit 0. Ablation (one, on the record-key detection): scripts/ablation-replace.mjs wrap mode replaced 'if (other !== undefined) {' with 'if (other !== undefined && other === ABLATED_23537) {' (anchor x1 -> x0, blob 73a5c5e26 -> cca8c00e4); metadata-protocol rebuilt; ablation-dist-preflight: marker in 2 dist files (index.js, index.cjs); pin 3 failed / 2 passed, each failure 'expected [] to have a length of 1 but got +0' (both permission orders + translation), hook case and negative control green (arm-specific). Restore: tool proved blob == HEAD 73a5c5e26 and git diff HEAD empty; whole-tree git status --porcelain empty; rebuild; ablation-dist-preflight --absent: marker in 0 of 24 dist files, tree clean; the three duplicate-package pin files 14 passed.",
      "gates": "Local: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 1b8e8e13b derived 67 commands (identical to the PM's pm-gates.txt at 7b64a3d73); all 67 run, each exit captured before any pipe; 65 exit 0 on first run; pnpm check:lean-entry-closure exit 3 PREREQUISITE NOT MET (objectql dist absent) -> reran exit 0 after pnpm --filter @objectstack/objectql build; pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (needs the whole workspace built) = NOT MEASURED, left to CI. --ran reconciliation: exit 0, '67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN'. Downstream consumers of @objectstack/metadata-protocol not run locally (d.ts changed only in the method docblock; CI runs them). origin/main moved 2 commits (b9b618942 cli, 0fb55fc2f mcp), disjoint; git merge-tree --write-tree clean; no merge. CI (PR #23545 head 1b8e8e13b, snapshot at report time, 23 check-runs; not waited on): Check Changeset: in_progress; Check Documentation Links: in_progress; Check PR Size: in_progress; Flag docs affected by code changes: in_progress; Governed Surface Queue Guard: in_progress; Lint · CI, release & engine gates: in_progress; Lint · ESLint: in_progress; Lint · PM dispatch-gates: in_progress; Lint · docs & code guards: in_progress; Lint · repo & tooling gates: in_progress; Lint · scoped · query-options erasure: in_progress; Lint · scoped · slot lookup & corpus: in_progress; Lint · scoped · tooling self-tests: in_progress; No other open PR may claim the same issue: completed/success; No other open PR may claim the same single-writer path: in_progress; Packed-tarball smoke (opt-in): completed/skipped; Part-of PR must not also close its card: in_progress; The card this PR closes must claim this branch: in_progress; Type Check · consumer gates: in_progress; Type Check · debt ledger: in_progress; Type Check · source gates: in_progress; Type Check · workspace: in_progress; filter: in_progress",
      "mcp_calls": "0 — no MCP GitHub tool was called",
      "api_writes": "2 relay strokes, 3 REST writes, all as objectstack-fleet[bot] via scripts/pm/fleet-write/dispatch.mjs (each stroke = POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/23545/assignees ['huangyiirene']; read-back 11741 bytes sent / 11741 stored, identical. (2) this os-dev-report -> POST /repos/objectstack-ai/objectstack/issues/23537/comments. label-write: 0 (the dispatch named no PR label; skip-changeset does not apply, a published package ships a patch changeset). git push x3 (empty-branch probe, 2 commits), not REST.",
      "open_questions": [
        {
          "question": "H4: is the hook-array arm inside the dispatch's 'fix it here only if it drops or widens something' condition? Measured: the copied hook's handler ran twice per insert on the copied object (the source ran once per object); nothing is dropped.",
          "options": [
            "A keep the array arm (one detection in the same walker, pinned by one case)",
            "B drop the array arm (one hunk of deepRewrite plus the hook case), and file the doubled binding as its own card"
          ],
          "recommendation": "A, because a doubled handler run is a widening of the hook's effect for any non-idempotent handler (a counter, a notification), it is the same defect class in the same walker, and B would ship a known silent behaviour change in the very code this PR hardens."
        }
      ],
      "out_of_scope_findings": [],
      "line_budget": "n/a (no skills/**)",
      "deviations": [
        "H4 fixed in place (the array arm) as a bounded in-place fix, stated as a judgement call in the PR body and open_questions.",
        "The one ablation targets the record-key arm only (the card's subject); the array arm is pinned but not ablated.",
        "A one-off probe test (packages/objectql/src/zz-probe-23537.test.ts) measured H1/H2/H4 at base and head; it was never committed and was deleted from the worktree (copy in the scratchpad).",
        "Commit trailers use the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude) and the PR body uses the AGENTS.md session-URL footer, not the harness reminder's model-named trailer and footer text.",
        "Worktree cleanup (rm -rf node_modules, git worktree remove without --force) was done before this comment was posted; the comment was sent with the shared checkout's scripts/pm/fleet-write/dispatch.mjs (read-only use, no edit to that tree)."
      ],
      "files_changed": [
        "packages/metadata-protocol/src/protocol.ts (+72/-4, duplicatePackage region only)",
        "packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts (new, 5 cases)",
        ".changeset/23537-duplicate-package-reference-collision.md (@objectstack/metadata-protocol patch)"
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23545 comment 6179475421 · domain:spec seat 1 (#6017) · session session_01YRp5z4kK6vgYkrdH97ztsy · 2026-10-14T22:03Z


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23545 → 8226755ee (Fixes #23537) · 2026-10-14T22:57Z

    domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · holder of claim 6178643446. Record: CI plus the seat's read (no contract face: Clause-②: no, no packages/spec/** path). ACCEPT ## 席位核对 6179475421 on 1b8e8e13b.

    Reading Probe Answer
    The PR GET /pulls/23545 merged: true at 22:56:44Z, merge_commit_sha 8226755ee
    The queue branch GET /git/matching-refs/heads/gh-readonly-queue none for #23545: the merge group was consumed
    origin/main git merge-base --is-ancestor 8226755ee origin/main yes. 8226755ee is the tip, a squash on 0fb55fc2f
    The card GET /issues/23537 closed / completed at 22:56:46Z, on the merge

    Out of scope: none reported. #23538 (the same duplicatePackage region, pm:blocked behind this PR) is now unblocked by this landing.

    State change: this act removes pm:dispatched.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions