Repository navigation
fix(metadata-protocol): duplicatePackage refuses a copied item whose renamed object reference meets a name it already holds - #23545
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
席位核对ACCEPT · No contract face, so no review is owed beyond this one: no published schema, no governed text, 1. Shape
2. Premises measured
3. Gates
4. Deviations accepted
5. Out of scope: none reported. #23538 (the field 6. Landing: Tier S, ordinary queue, once the head is all green. Generated by Claude Code |
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, itsdeepRewriterenames 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 withfailed: [].Now, in the
duplicatePackageregion only:The detection lives where the name is renamed.
deepRewritetakes a per-itemcollisionslist and records every meeting at a reference site: the path, the name that moved and the name it met.renameMapis one-to-one (a claimed copy name is refused up front since PR fix(metadata-protocol): duplicatePackage copies an object whose name lacks the source namespace prefix into the target namespace #23525), so a meeting is always one renamed name and one name this copy does not rename.One call site refuses the item. Right after
deepRewrite(item, collisions), a non-empty list pushes onefailed[]entry andcontinues, beforesaveMetaItem. Nothing of the item is written: no item row and no history, commit or audit row (measured below). It is the same "refused, never merged" posture asrewriteFieldRuleKeys(PR fix(metadata-protocol): duplicatePackage points a copied permission set's field-level rules at the copied objects #23536) and the claimed-name refusal (PR fix(metadata-protocol): duplicatePackage copies an object whose name lacks the source namespace prefix into the target namespace #23525).The refusal names the item, the property, both names and the source edit that clears it. Measured text:
An item with several collisions names the first and adds "It has N more such collision(s)."
The
duplicatePackagedocblock gains one sentence. No other region ofprotocol.tsis touched. The source-rowwhereline that PR feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469 edits is untouched, andreference-sites.tsis untouched.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.objects: { dupsrc_customer: { allowRead }, dupdst_customer: { allowRead, allowEdit, allowDelete } }copies withfailed: []asobjects: { 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.sys_metadata,sys_metadata_history,sys_metadata_commitorsys_metadata_auditmentionsdupdst_objs. In the same run, the copied objectdupdst_customerappears insys_metadata,sys_metadata_historyandsys_metadata_audit, so the scan reads the tables a copy writes. The pin asserts both halves.object: ['dupsrc_customer', 'dupdst_customer']copies withfailed: []asobject: ['dupdst_customer', 'dupdst_customer'].bindHooksToEngine): the copy's handler ran twice per insert ondupdst_customer. The source hook ran once per object.normalizeObjectsdoes 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.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 at7b64a3d73withtsx), plus a grep ofpackages/spec/srcfor record- or array-valued declarations of the same property names.deepRewritereads these properties by NAME, wherever they occur in any item.object)objectspermissionfailed: [](H1)objectstranslation(TranslationItemSchema){ dupsrc_customer: { label: 'KEHU-A' }, dupdst_customer: { label: 'KEHU-B' } }copied as{ dupdst_customer: { label: 'KEHU-B' } },failed: []objecthook(stringorstring[])objectapi,dashboard,dataset,field,object,page,permission,seed,viewobjectNameaction,app,object,tool, and flow node configsreferenceobject(lookup fields)targetObjectmappingThe other record- or array-valued
objectsdeclarations inpackages/spec/src(websocket,manifest,app-install,deploy-bundle,stack,solution-blueprint, the API's effective-permission answer) are not stored metadata item types, soduplicatePackagenever 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.objectsandhook.object(with its negative control).Pins
New file:
packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts, besideprotocol-duplicate-package-permission-field-keys.test.ts. It reads the stored rows out of the driver, never a payload.7b64a3d73(probe)objectskey 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 itfailed: []failed: []{ dupdst_customer: { allowRead: true } })objectskey meets one it holds is refused, nothing written. NEGATIVE CONTROL: the object still copied, source untouchedfailed: []objectentry meets one it holds is refused, nothing written. NEGATIVE CONTROL: the same hook without the second entry copies, its entry following the renamed objectfailed: []Ablation (one, on the collision detection)
if (other !== undefined) {, becameif (other !== undefined && other === 'ABLATED_23537') {, so no record-key meeting is recorded. It ran throughscripts/ablation-replace.mjsin wrap mode: anchor x1 to x0, blob73a5c5e26tocca8c00e4.metadata-protocolrebuild,ablation-dist-preflightfound 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.73a5c5e26, andgit diff HEADis empty. The whole-treegit status --porcelainwas empty. After a rebuild,ablation-dist-preflight --absentfound 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-protocoltypecheck: exit 0.objectqltypecheck: exit 0, andcheck:test-typecheckis OK with its debt unchanged (40 files / 234 errors / 65 signatures). The new file is in thetsconfig.test.jsonprogram (--listFilesOnly: 1 hit).eslint --no-inline-configon the two changed source files: exit 0, no output.node scripts/pm/dispatch-gates.mjs --commandsderived 67 families at1b8e8e13b, the same 67 commands as the PM's derivation at7b64a3d73. All were run locally, and--ranwith recorded exit codes reconciles: 67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN.check:lean-entry-closurefirst refused its prerequisite (exit 3,packages/objectql/dist/core.*absent). It reran with exit 0 after anobjectqlbuild.check:dual-build-cjs-loadsis NOT MEASURED:PREREQUISITE NOT MET(exit 3), because it needs the whole workspace built. It is left to CI.@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/mainhas moved two commits since the base (b9b618942, cli;0fb55fc2f, mcp), both disjoint from this diff.git merge-tree --write-treeis clean. No merge was made; the PR's merge-ref CI covers the joint state.Acceptance notes
deepRewriteand its pin is one case.['dupsrc_customer', 'dupsrc_customer']becomes['dupdst_customer', 'dupdst_customer']). The copy does exactly what the source does. Read from the code, not pinned.Out of scope
Nothing new found. #23538's two defects (the field
namehalf-rename and the view-item name) are untouched.Generated by Claude Code