Repository navigation
Commit 8226755
fix(metadata-protocol): duplicatePackage refuses a copied item whose renamed object reference meets a name it already holds (#23545)
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:
- **The detection lives where the name is renamed.** `deepRewrite` takes
a per-item `collisions` list and records every meeting at a reference
site: the path, the name that moved and the name it met.
- Record keys: a second source key landing on a key already set in the
same record.
- Array entries: a renamed entry landing on an entry already in the same
array, when the two source entries differ. An entry the source itself
repeats is copied as stored.
- `renameMap` is one-to-one (a claimed copy name is refused up front
since PR #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 one `failed[]` entry and
`continue`s, before `saveMetaItem`. 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 as `rewriteFieldRuleKeys` (PR
#23536) and the claimed-name refusal (PR #23525).
- **The refusal names the item, the property, both names and the source
edit that clears it.** Measured text:
> Permission set 'dupsrc_objs' is not copied: its `objects` key
'dupsrc_customer' follows the copied object to 'dupdst_customer', a key
it already holds there, which this copy does not rename. A record keeps
one entry per key, so the copy would drop one of the two. Remove
'dupsrc_customer' or 'dupdst_customer' on the source, then duplicate
again.
An item with several collisions names the first and adds "It has N more
such collision(s)."
- The `duplicatePackage` docblock gains one sentence. No other region of
`protocol.ts` is touched. The source-row `where` line that PR #23469
edits is untouched, and `reference-sites.ts` is 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.
- **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](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0fb55fc commit 8226755
3 files changed
Lines changed: 335 additions & 4 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
26327 | 26327 | | |
26328 | 26328 | | |
26329 | 26329 | | |
| 26330 | + | |
| 26331 | + | |
| 26332 | + | |
| 26333 | + | |
| 26334 | + | |
26330 | 26335 | | |
26331 | 26336 | | |
26332 | 26337 | | |
| |||
26550 | 26555 | | |
26551 | 26556 | | |
26552 | 26557 | | |
| 26558 | + | |
| 26559 | + | |
| 26560 | + | |
| 26561 | + | |
| 26562 | + | |
| 26563 | + | |
| 26564 | + | |
| 26565 | + | |
| 26566 | + | |
| 26567 | + | |
| 26568 | + | |
| 26569 | + | |
| 26570 | + | |
| 26571 | + | |
| 26572 | + | |
| 26573 | + | |
26553 | 26574 | | |
26554 | 26575 | | |
26555 | 26576 | | |
26556 | | - | |
| 26577 | + | |
| 26578 | + | |
26557 | 26579 | | |
26558 | 26580 | | |
26559 | 26581 | | |
26560 | 26582 | | |
26561 | 26583 | | |
26562 | | - | |
| 26584 | + | |
| 26585 | + | |
| 26586 | + | |
| 26587 | + | |
| 26588 | + | |
| 26589 | + | |
| 26590 | + | |
| 26591 | + | |
| 26592 | + | |
| 26593 | + | |
| 26594 | + | |
| 26595 | + | |
| 26596 | + | |
| 26597 | + | |
| 26598 | + | |
| 26599 | + | |
26563 | 26600 | | |
26564 | 26601 | | |
| 26602 | + | |
26565 | 26603 | | |
26566 | 26604 | | |
26567 | | - | |
| 26605 | + | |
| 26606 | + | |
| 26607 | + | |
| 26608 | + | |
| 26609 | + | |
| 26610 | + | |
| 26611 | + | |
| 26612 | + | |
| 26613 | + | |
| 26614 | + | |
26568 | 26615 | | |
26569 | 26616 | | |
26570 | 26617 | | |
26571 | 26618 | | |
26572 | 26619 | | |
| 26620 | + | |
| 26621 | + | |
| 26622 | + | |
| 26623 | + | |
| 26624 | + | |
| 26625 | + | |
| 26626 | + | |
26573 | 26627 | | |
26574 | 26628 | | |
26575 | 26629 | | |
| |||
26738 | 26792 | | |
26739 | 26793 | | |
26740 | 26794 | | |
26741 | | - | |
| 26795 | + | |
| 26796 | + | |
| 26797 | + | |
| 26798 | + | |
| 26799 | + | |
| 26800 | + | |
| 26801 | + | |
| 26802 | + | |
| 26803 | + | |
| 26804 | + | |
| 26805 | + | |
| 26806 | + | |
| 26807 | + | |
| 26808 | + | |
| 26809 | + | |
26742 | 26810 | | |
26743 | 26811 | | |
26744 | 26812 | | |
| |||
0 commit comments