Repository navigation
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
Description
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
duplicatePackage's docblock (ADR-0070 D4): the copy is made by "rewriting every intra-package reference". A rewrite that drops a grant does not hold that.- PR fix(metadata-protocol): duplicatePackage copies an object whose name lacks the source namespace prefix into the target namespace #23525's own posture for a claimed object name: refuse up front, never invent or merge.
What done looks like
- A copied set whose renamed
objectskey equals a key it already holds is listed infailed[], 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:
- metadata-protocol: duplicatePackage leaves a copied permission set's
fieldskeys (OBJECT.FIELD) naming the SOURCE object, so the duplicate's field-level security applies to the source #23527 (open): thefieldskeys, notobjects. - metadata-protocol: duplicatePackage cannot duplicate an object whose name lacks the source namespace prefix — the copy keeps the name and becomes a row the registry never holds, or (after PR #23510) a failure with a prescription that does not fit #23511, metadata-protocol: a save into a second package of an object a runtime-authored row owns answers 200 while the registry refuses the ownership —
classifyObjectContributionprotects code owners only (ADR-0029 D9.9) #23416, metadata-protocol: three package-axis gaps PR #23290's review leaves open — the registry heal after a per-package delete, the draft save's storage-order bind with two packages, and the delete_lockgate that takes no package #23297 (closed): object re-namespacing and package-axis ownership. - plugin-security: the permission-set projection and the position write-through delete metadata by name with no package — after #23165 a name two packages hold answers 409, and
storedRowPackageArg(#21861) is redundant #23292, plugin-security: a data-door edit of a permission set saved into a writable runtime package forks it — the write-through's update leg saves without the row's package, leaving two activesys_metadatarows for one name #21861, plugin-security: discard-overlay deletes the only stored row of a permission set saved into a writable runtime package — its eligibility reads "has a package id" as "package-declared", the defect #21789 fixes in the lock #21860 (closed): permission-set write-through and discard. - finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128, decision: #21980 merge condition — a no-package by-name read of a shared view name now answers the stored copy, not the owner's shipped item (before the change it depended on registry order): land PR #22023, or hold it? #22027 (closed): draft and view reads.
- [finding] A package's whole declared permission set is dropped on a set-name collision with no diagnostic and no build gate — the comment says "refuse loudly", the code warns through a doubly-optional logger #17516 (closed): a whole set dropped on a set-NAME collision at install.
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
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade ·
bug·security·priority:p2·domain:spec·pm:queue· land after PR #23536 · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T20:53ZRead fresh.
duplicatePackage's record-key rule keeps the last entry when a renamedobjectskey 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.security: the copy can be wider than its source, silently.priority:p2: a measured producer defect on a declared contract (ADR-0070 D4). It needs a source set that already names an object in the target namespace, which is the narrow case. This matches metadata-protocol: duplicatePackage leaves a copied permission set'sfieldskeys (OBJECT.FIELD) naming the SOURCE object, so the duplicate's field-level security applies to the source #23527's grade.failed[], as PR fix(metadata-protocol): duplicatePackage points a copied permission set's field-level rules at the copied objects #23536 does forfieldskeys.domain:spec: the package-axis family (metadata-protocol: duplicatePackage cannot duplicate an object whose name lacks the source namespace prefix — the copy keeps the name and becomes a row the registry never holds, or (after PR #23510) a failure with a prescription that does not fit #23511, metadata-protocol: duplicatePackage leaves a copied permission set'sfieldskeys (OBJECT.FIELD) naming the SOURCE object, so the duplicate's field-level security applies to the source #23527). It lands after PR fix(metadata-protocol): duplicatePackage points a copied permission set's field-level rules at the copied objects #23536, which touches the same region.findingleaves on grading.objectstack-fleet commentedon Oct 14, 2026
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 infailed[]with both keys, never merging. The first case is a permission set'sobjects, following PR #23536's "refused, never merged" shape forfieldskeys. Pinned on a real kernel; triage6178230551, landing after PR #23536, which merged as7b64a3d73) · 2026-10-14T21:13ZSession:
session_01YRp5z4kK6vgYkrdH97ztsyAccount:
huangyiirene(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23537-duplicate-objects-key-collisionWorktree:
objectstack-issue-23537Domain:
domain:specSeat:
domain:spec#1(seat post #6017)File surface (at
origin/main7b64a3d73or later; stop on a breach and explain it in the report):packages/metadata-protocol/src/protocol.ts: theduplicatePackageregion only (near:26331), its record-key rule indeepRewrite(near:26566) and the call site that pushesfailed[].packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts..changeset/23537-*.md.packages/metadata-protocol/src/reference-sites.ts. ⛔ Not any other region ofprotocol.ts. ⛔ Notpackages/spec/**.Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate). No contract face: no published schema, no governed text, noClause-②: yes. CI plus the seat's own read clear it.Clause-②: no
Responsibility:
metadata-protocol'sduplicatePackage, whose record-key rule (PR fix(metadata-protocol): duplicatePackage copies an object whose name lacks the source namespace prefix into the target namespace #23525) writeso[renamed] = …and lets the last entry win when a renamed key meets an existing one. | PR fix(metadata-protocol): duplicatePackage points a copied permission set's field-level rules at the copied objects #23536 (metadata-protocol: duplicatePackage leaves a copied permission set'sfieldskeys (OBJECT.FIELD) naming the SOURCE object, so the duplicate's field-level security applies to the source #23527) refuses the same collision forfieldskeys and handed this one to the seat. | Anyone duplicating a package whose permission set already names an object in the target namespace: the copy can grant edit and delete the source never granted on its own object.Thread-read: 6178230551
Serial constraints cleared:
duplicatePackage's source-row read (organization_id: nullat:26454), not the rewrite: disjoint lines, ordinary concurrency; the later lander mergesmain.deletePackage) edits another method ofprotocol.ts.name, and a bare object's view item keeps itsOBJECT.VIEWKEYname #23538 (the same region) is not claimed; this seat dispatches serially, so it waits for this card.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
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
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23545 comment 6179475421 ·
domain:specseat 1 (#6017) · sessionsession_01YRp5z4kK6vgYkrdH97ztsy· 2026-10-14T22:03ZGenerated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Landed: PR #23545 →
8226755ee(Fixes #23537) · 2026-10-14T22:57Zdomain:specseat 1 (#6017) ·huangyiirene· sessionsession_01YRp5z4kK6vgYkrdH97ztsy· holder of claim6178643446. Record: CI plus the seat's read (no contract face:Clause-②: no, nopackages/spec/**path). ACCEPT## 席位核对6179475421on1b8e8e13b.GET /pulls/23545merged: trueat 22:56:44Z,merge_commit_sha8226755eeGET /git/matching-refs/heads/gh-readonly-queueorigin/maingit merge-base --is-ancestor 8226755ee origin/main8226755eeis the tip, a squash on0fb55fc2fGET /issues/23537closed/completedat 22:56:46Z, on the mergeOut of scope: none reported. #23538 (the same
duplicatePackageregion,pm:blockedbehind this PR) is now unblocked by this landing.State change: this act removes
pm:dispatched.Generated by Claude Code