Skip to content

fix(metadata-protocol): duplicatePackage refuses a copied item whose renamed object reference meets a name it already holds - #23545

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-23537-duplicate-objects-key-collision
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-23537-duplicate-objects-key-collision

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23537
Clause-②: no

What this does

duplicatePackage (packages/metadata-protocol/src/protocol.ts, ADR-0070 D4) promises to rewrite "every intra-package reference". At a property that names an object, its deepRewrite renames a name whole: a string, an array entry, or a record KEY. When the source item also names the object the copy is about to be called (dupdst_customer, an object outside the package or not yet created) beside the package's own (dupsrc_customer), the rename lands on a name the item already holds. Until now the copy merged the two with failed: [].

Now, in the duplicatePackage region only:

Zone 2 hypotheses, measured on a real kernel (ObjectKernel + ObjectQLPlugin, memory driver)

A one-off probe file (not committed) read the stored rows out of the driver at base 7b64a3d73, then at head.

  • H1 holds. At base, a source set objects: { dupsrc_customer: { allowRead }, dupdst_customer: { allowRead, allowEdit, allowDelete } } copies with failed: [] as objects: { dupdst_customer: { allowRead, allowEdit, allowDelete } }. The last entry in insertion order wins. With the two keys in the other order, the copy is { dupdst_customer: { allowRead } }, so the wide grant is the one dropped. Both orders are pinned.
  • H2: the collision reaches every record-keyed or array-valued reference map, and the fix covers them all at once. See the enumeration below.
  • H3: nothing of a refused item is written. At head, after the refusal, no row in any of sys_metadata, sys_metadata_history, sys_metadata_commit or sys_metadata_audit mentions dupdst_objs. In the same run, the copied object dupdst_customer appears in sys_metadata, sys_metadata_history and sys_metadata_audit, so the scan reads the tables a copy writes. The pin asserts both halves.
  • H4 changes an answer, so it is fixed here. At base, a hook with object: ['dupsrc_customer', 'dupdst_customer'] copies with failed: [] as object: ['dupdst_customer', 'dupdst_customer'].
    • Measured through the binder the authored-hook resync calls (bindHooksToEngine): the copy's handler ran twice per insert on dupdst_customer. The source hook ran once per object.
    • A grant duplicated in a record is merged into one key. A duplicated array entry is not merged: the binder registers each entry, and normalizeObjects does not deduplicate. So a non-idempotent handler (a counter, a notification) does twice what its source does. I read that as widening the item's effect, which is the dispatch's condition for fixing it here. It is the same detection in the same walker. Say so if you read the condition otherwise, and the array arm comes out as one hunk.
    • In this kernel composition, ObjectQLPlugin did not bind hooks saved through saveMetaItem (no metadata service, so no engine registration for the source hook or the copy). That is why the double run was measured through the binder directly.

H2: the reference maps a copied item can carry

Method: the derived site index (REFERENCE_SITES.byTarget.get('object'), read at 7b64a3d73 with tsx), plus a grep of packages/spec/src for record- or array-valued declarations of the same property names. deepRewrite reads these properties by NAME, wherever they occur in any item.

property (site index, target object) declared on value shape can collide? measured at base
objects permission record keyed by object name yes a grant dropped, failed: [] (H1)
objects translation (TranslationItemSchema) record keyed by object name yes one object's labels dropped: { dupsrc_customer: { label: 'KEHU-A' }, dupdst_customer: { label: 'KEHU-B' } } copied as { dupdst_customer: { label: 'KEHU-B' } }, failed: []
object hook (string or string[]) array entries yes the copied object named twice, the handler ran twice (H4)
object api, dashboard, dataset, field, object, page, permission, seed, view string no: one string cannot meet another not measured
objectName action, app, object, tool, and flow node configs string no not measured
reference object (lookup fields) string no not measured
targetObject mapping string no not measured

The other record- or array-valued objects declarations in packages/spec/src (websocket, manifest, app-install, deploy-bundle, stack, solution-blueprint, the API's effective-permission answer) are not stored metadata item types, so duplicatePackage never copies them.

The fix covers any record or array at any of these five properties, wherever it occurs. Three are pinned: permission.objects (both orders, with its negative control), translation.objects and hook.object (with its negative control).

Pins

New file: packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts, beside protocol-duplicate-package-permission-field-keys.test.ts. It reads the stored rows out of the driver, never a payload.

case at base 7b64a3d73 (probe) record-key detection ablated at head
a permission set whose renamed objects key meets one it holds is refused (the renamed key first). Located refusal, nothing written anywhere, source untouched. NEGATIVE CONTROL: the object still copied, and the write scan finds it merged, failed: [] red green
the same, with the key it meets first merged, failed: [] red green
NEGATIVE CONTROL: the same set without the second key copies, and its one grant follows the renamed object ({ dupdst_customer: { allowRead: true } }) green (copies as today) green green
a translation whose renamed objects key meets one it holds is refused, nothing written. NEGATIVE CONTROL: the object still copied, source untouched merged, failed: [] red green
a hook whose renamed object entry meets one it holds is refused, nothing written. NEGATIVE CONTROL: the same hook without the second entry copies, its entry following the renamed object duplicated entry, failed: [] green (the array arm is not ablated) green

Ablation (one, on the collision detection)

  • The mutation. The record-key detection, if (other !== undefined) {, became if (other !== undefined && other === 'ABLATED_23537') {, so no record-key meeting is recorded. It ran through scripts/ablation-replace.mjs in wrap mode: anchor x1 to x0, blob 73a5c5e26 to cca8c00e4.
  • The ablated run. After a metadata-protocol rebuild, ablation-dist-preflight found the marker in 2 dist files (index.js, index.cjs). The pin went 3 failed / 2 passed. Each of the 3 failed for the base reason, expected [] to have a length of 1 but got +0: the two permission-set cases and the translation case. The hook case and the negative control stayed green, so the ablation is specific to the record-key arm.
  • The restore. The tool proved it: the blob equals HEAD 73a5c5e26, and git diff HEAD is empty. The whole-tree git status --porcelain was empty. After a rebuild, ablation-dist-preflight --absent found the marker in none of 24 dist files and the tree clean against HEAD. The three duplicate-package pin files then went 14 passed (5 new, 5 in …-permission-field-keys, 4 in …-unprefixed-object).

Tests and gates (at 1b8e8e13b)

  • 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 and 8113 tests passed, the new file included (lock VERDICT command-exit 0).
  • metadata-protocol typecheck: exit 0.
  • objectql typecheck: exit 0, and check:test-typecheck is OK with its debt unchanged (40 files / 234 errors / 65 signatures). The new file is in the tsconfig.test.json program (--listFilesOnly: 1 hit).
  • eslint --no-inline-config on the two changed source files: exit 0, no output.
  • node scripts/pm/dispatch-gates.mjs --commands derived 67 families at 1b8e8e13b, the same 67 commands as the PM's derivation at 7b64a3d73. All were run locally, and --ran with recorded exit codes reconciles: 67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN.
    • check:lean-entry-closure first refused its prerequisite (exit 3, packages/objectql/dist/core.* absent). It reran with exit 0 after an objectql build.
    • check:dual-build-cjs-loads is NOT MEASURED: PREREQUISITE NOT MET (exit 3), because it needs the whole workspace built. It is left to CI.
  • Not run locally: the downstream consumers of @objectstack/metadata-protocol. The declaration files changed only in this method's docblock, and the behaviour change is the refusal itself, which no other package seeds. CI runs them.
  • origin/main has moved two commits since the base (b9b618942, cli; 0fb55fc2f, mcp), both disjoint from this diff. git merge-tree --write-tree is clean. No merge was made; the PR's merge-ref CI covers the joint state.

Acceptance notes

  • H4 is a judgement call, stated above. The array arm refuses a hook whose renamed entry meets one it holds. If the reviewer reads the dispatch's "drops or widens" condition as not met by a doubled handler run, the arm is one hunk of deepRewrite and its pin is one case.
  • An entry the source itself repeats is copied as stored (['dupsrc_customer', 'dupsrc_customer'] becomes ['dupdst_customer', 'dupdst_customer']). The copy does exactly what the source does. Read from the code, not pinned.
  • Read only, not measured: the five string-valued sites in the table cannot collide by shape.

Out of scope

Nothing new found. #23538's two defects (the field name half-rename and the view-item name) are untouched.


Generated by Claude Code

…d reference meets a name it already holds

WIP: the collision detection in deepRewrite and the failed[] call site.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
… that meets a held name; changeset

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2d896d7f9f791e4067d137e14de9a6b7585e7992 — the merge of head 1b8e8e13bdf223390c4601214ba6c3086574ff3b into base 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2d896d7f9f791e4067d137e14de9a6b7585e7992 && git checkout 2d896d7f9f791e4067d137e14de9a6b7585e7992
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 1b8e8e13bdf223390c4601214ba6c3086574ff3b && git checkout -B drift-repro 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 && git merge --no-ff 1b8e8e13bdf223390c4601214ba6c3086574ff3b

node scripts/docs-audit/affected-docs.mjs --json 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · head 1b8e8e13b · 2026-10-14T22:02Z

No contract face, so no review is owed beyond this one: no published schema, no governed text, Clause-②: no. CI and this seat's own read clear it. Card #23537, claim 6178643446, dev report on #23537.

1. Shape

  • Draft, base main. Line 1 Fixes #23537, line 2 Clause-②: no. No other closing keyword.
  • 3 files, +335 / -4:
    • protocol.ts +72/-4, the duplicatePackage region only: a docblock sentence, deepRewrite's collision record, describeCollision, and the one call site;
    • the new real-kernel pin (5 cases);
    • the changeset.
  • deepRewrite has one outside call site, and it refuses the item in failed[] and continues before saveMetaItem, so nothing of a refused item is written. The pin scans sys_metadata, its history, commit and audit rows for that, with the copied object as the scan's control.
  • No false trigger: a key is renamed only under a reference property, and outside one the record's keys are unique, so from.get(key) cannot hit. An entry the source itself repeats is copied as stored (v[j] !== v[i]).
  • The changeset is a @objectstack/metadata-protocol patch. Its FROM / TO / Unchanged paragraphs match the code.

2. Premises measured

  • H1 held at 7b64a3d73: the colliding permission set copied with failed: [], keeping the last entry. In one key order the wide grant is kept, in the other it is dropped.
  • H2: the same walker also merged a translation's objects (a label dropped). The fix sits where the key is renamed, so it covers all five object-reference properties at once. The other sites are string-valued and cannot collide.
  • H3: the refusal is collected per item and reported at the one call site.
  • H4: a hook whose object list held both names was copied as ['dupdst_customer', 'dupdst_customer'], and its handler ran twice per insert through bindHooksToEngine.

3. Gates

  • Check-runs on 1b8e8e13b at this act: 14 success, 6 skipped, 17 in progress, 0 failures. The flip waits for green.
  • Local: --ran over 67 families, 66 run. 1 is NOT MEASURED: check:dual-build-cjs-loads, exit 3, which needs a whole-workspace build. CI runs it.
  • The real-kernel pin: all three collision shapes answered failed: [] at base (an uncommitted probe, deleted). At head, the three duplicate-package pin files pass 14 of 14.
  • Ablation on the record-key detection: 3 failed / 2 passed. The arm-specific failures are both permission orders and the translation; the hook case and the negative control stay green. The restore is proven.
  • metadata-protocol: 28409 passed. objectql local: 8113 passed. Both typechecks exit 0, and the new file is in the test program.

4. Deviations accepted

  • The array arm (H4; the dev's open question, option A) is kept. The dispatch said to fix it here only if it drops or widens something. A handler bound twice runs twice per event, which widens a non-idempotent hook's effect (a counter, a notification). It is the same defect class in the same walker, pinned by one case with a negative control. Shipping the hardened walker with a known silent doubling would be worse than one detection. It is not ablated; the base probe measured its case red.
  • Wider than permission.objects: the refusal covers every record key and array entry at an object-reference property (a translation's objects, a hook's object). H2 asked for exactly this reading. It is a bounded in-place fix in the same region, and the PR body enumerates what it covers.
  • main is not merged. Two disjoint commits (b9b618942 cli, 0fb55fc2f mcp) landed after the base, and merge-tree is clean (docs(os-dev, AGENTS, pm-dispatch): local verification = changed packages typecheck/tests + families reading changed files; main merged after PR open only on conflict, main-red or queue removal #23405).

5. Out of scope: none reported. #23538 (the field name half-rename, the bare view-item name) is the next card on this region. This seat holds it until this PR lands, because both edit deepRewrite.

6. Landing: Tier S, ordinary queue, once the head is all green.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 22:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 22:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 8226755 Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23537-duplicate-objects-key-collision branch October 14, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants