Skip to content

fix(metadata-protocol): duplicatePackage copies an object whose name lacks the source namespace prefix into the target namespace - #23525

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23511-duplicate-package-unprefixed
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23511-duplicate-package-unprefixed

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23511
Clause-②: no

What this does

duplicatePackage (packages/metadata-protocol/src/protocol.ts, ADR-0070 D4) renamed a copied object only when its name started with the source namespace prefix (dupsrc_customer becomes dupdst_customer). An object whose name lacks the prefix (ticket) kept its name. An object name is its registry key and its table name, so that copy was a second claim on the source's object. On main at 222293f60 it lands in failed[] with the ADR-0029 D9.9 ownership text ("bind the customization to …, or have … extend the object instead"), which names no way out for a duplicate. Every copied item that referenced it still named the source's object.

Now, in the duplicatePackage region only:

  • Every copied object takes a name in the target namespace. A prefixed name swaps its prefix, unchanged. A name without the source prefix gets the target prefix in front of its whole name: ticket becomes dupdst_ticket. That is the rename the namespace-prefix rule itself prescribes (validateObjectNamespacePrefix: "Rename it to NAMESPACE_NAME"). The copy gets its own registry entry and its own table.
  • References to such a name are rewritten where a property names an object, and only on an exact match. A bare word cannot be found safely in free text. Measured: putting ticket into the existing free-text rewrite turns "A support ticket raised by a customer" into "A support dupdst_ticket raised by a customer". The same match would also rename the value of a lookup field called ticket. So a bare name is rewritten only at the derived object-reference properties (REFERENCE_SITES, target object: reference, object, objectName, objects, targetObject). The rule reads the property wherever it occurs in any item, because a flow binds its object on a node config.objectName, which the index does not walk. It covers the four shapes a name takes there: a string, an array element, a record key, or a .name.
  • A name that is already taken is refused up front, at the object. The name can be taken by a prefixed sibling's copy (ticket and dupsrc_ticket both become dupdst_ticket) or by an object the source package holds. In that case the object is listed in failed[] before anything is written for it. The entry names the object, the name it would take, the holder of that name, and the rename on the source that clears it. No second name is invented.
  • Bounded in-place fix, same defect class. The record-key rule also applies to prefixed names. Measured on main: a copied permission set kept objects: { dupsrc_customer, ticket }, which granted on the SOURCE's objects. It now holds dupdst_customer and dupdst_ticket. It is the same walker in the same region, pinned by the references case below, and it adds no gate surface. Every other failed[] cause and every non-object item name is unchanged.

The H2 reading: rename, and refuse only a claimed name

  • ADR-0070 D4 docblock (the contract): it clones every ACTIVE item "RE-NAMESPACING object names — … sys_metadata keys on (type,name,org), so a same-name copy would collide with the source".
  • ADR-0129 D2 (the governing naming record): "A package declares its prefix once (manifest.namespace), and every object it defines MUST be named ${namespace}_${shortName} (platform-reserved sys_* names exempt)."
  • ADR-0028 is Deferred. Its status line records the literal-prefix rule as the enforced current state, and ADR-0129 D4 says "Until 0028's recorded re-open trigger fires, this ADR is the governing record of object naming." So the rule applied is ADR-0129's literal prefix, ${targetNs}_${name} with a single underscore. The __ form is the legacy FQN form, which the prefix rule refuses.
  • The target namespace is always known at the copy loop. A missing or invalid one is refused 400 before anything is minted, so the copy is renamed whenever its name is free. The copy is a NEW object, so the rename costs nothing. Renaming the SOURCE object (the alternative way out) would move its table.
  • The "illegal name" arm is unreachable, so no code was added for it. The namespace declaration (^[a-z][a-z0-9_]{1,19}$) plus _ plus a name the object declaration admitted (^[a-z_][a-z0-9_]*$) is always admitted. No length cap is declared on object names, so H2's length check has nothing to check.
  • "Existing names" is read as the names this copy and its source hold. A collision with a third package's registry entry is answered by the save door's own gates, exactly as for a prefixed copy, and is unchanged.

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

  • H1 holds as guessed. At 222293f60 the rename is a prefix swap, and a name without the prefix is left unchanged. The rename map holds only the swapped objects. References are rewritten by one free-text regex over every string value, and record keys are never touched. Measured: the copied dupdst_note.fields.ticket.reference stayed ticket (the source's object), and the copied permission set kept both source keys.
  • H2: see above. The PM route (the same map through the free-text regex) is the measured prose corruption, so the bare name goes through the structural rewrite instead.
  • H3 holds. At 222293f60, ticket is in failed[] with the D9.9 text "Cannot layer object 'ticket': the overlay is bound to package 'com.qa.dupdst', but the object is owned by package 'com.qa.dupsrc' …". With this change that cause moves for this case only. The same pin file's other assertions and the package suites show no other cause moved.
  • H4 holds. A record inserted through dupdst_ticket lands in the dupdst_ticket table, and the ticket table holds only its own record. At base, dupdst_ticket does not exist (Object 'dupdst_ticket' not found).

Pins

The new file is packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts. It sits beside the existing duplicate tests in protocol-package-lifecycle.test.ts. The base column was read against a metadata-protocol dist built at 222293f60.

case at 222293f60 at head
a prefixed and a non-prefixed object both copy into the target namespace, each owned by the target; no target row under the source's name red: failed holds ticket with the D9.9 text green
references follow: lookup reference, view object, flow node config.objectName, permission objects keys. NEGATIVE CONTROLS: the prose and the lookup field called ticket (key and name) are unchanged, and the source is untouched red: no dupdst_ticket row green
the copy has its own table. NEGATIVE CONTROL: the source table holds only its own record red: Object 'dupdst_ticket' not found green
a claimed name is refused at the object (asserts on named subjects: the object, the name it would take, the holder, the rename). NEGATIVE CONTROL: the prefixed sibling took the name with its own body, and one history entry was written for it red: the D9.9 text instead green

Ablation (one, on the non-prefixed branch)

The mutation was for (const name of unprefixed) { replaced by for (const name of unprefixed.slice(0, 0)) {, so no bare name is renamed or refused. It ran through scripts/ablation-replace.mjs: anchor x1 to x0, blob c19415261 to 0de914530. After a rebuild, ablation-dist-preflight found the marker in 2 dist files. Result: 4 failed / 0 passed, for the base reasons (the D9.9 text, the missing copy, Object 'dupdst_ticket' not found). The restore was proven by the tool (blob equals HEAD c19415261, git diff HEAD empty). After a rebuild, the marker was absent from all 24 dist files, the tree was clean against HEAD, and the pin was 4 passed. The measurement of the PM route used the same discipline: olds widened to every renamed name, then 1 failed / 3 passed (the prose case), restored, marker absent, tree clean.

Tests and gates

  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 236 files passed, 3 skipped; 28397 tests passed, 19 skipped.
  • pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 417 files, 8101 tests passed.
  • Both suites ran at c2430b9b4 (lock VERDICT command-exit 0). e9c2bc9ad only rewords one code comment. After it, metadata-protocol was rebuilt and the 14 duplicate pins reran green.
  • metadata-protocol typecheck: exit 0.
  • objectql typecheck: exit 0. check:test-typecheck OK with its debt unchanged, and the new file is in the test program (--listFilesOnly).
  • node scripts/pm/dispatch-gates.mjs --commands (diff-derived, 67 families) was run locally and reconciled with --ran at e9c2bc9ad: 67 derived, 66 run with exit 0, 1 NOT MEASURED, 0 unrun.
    • check:lean-entry-closure first refused its prerequisite. It reran green after an objectql build.
    • check:dual-build-cjs-loads is NOT MEASURED: PREREQUISITE NOT MET, because it needs the whole workspace built. It is left to CI.
  • Origin/main has moved 7 commits since the branch point. One of them, the read naming no package, touches other regions of protocol.ts. git merge-tree against it is clean. No merge was made, and the PR's merge-ref CI covers the joint state.

Acceptance notes

  • A non-object item whose name lacks the source prefix still keeps its name, as the docblock says. Measured: the copy of the view container ticket_views is stored as a second row bound to the target. Its served views expand per object (ticket.default for the source, dupdst_ticket.default for the copy), and a read naming no package serves the source's row. No defect measured; noted.
  • A bare name inside free text (an expression, a description) is not rewritten, by design. A prefixed name still is, through the existing regex.
  • Read-only inference, not measured: the existing free-text regex has a right identifier boundary but no left one.
  • Not measured: physical identifier limits (63 bytes on PostgreSQL) for a longer target name. No object-name length cap is declared, and the prefixed path carries the same exposure.
  • Out of scope, handed to the seat: a copied permission set's fields map, keyed OBJECT.FIELD, keeps the source object's names. Measured at head: dupsrc_customer.full_name stays as is while objects follows. This is a composite key form the derived reference-site index does not model, so it is not fixed here.

Generated by Claude Code

…source prefix into the target namespace

An object whose name lacks the source namespace prefix kept its name in
the copy, a second claim on the source's registry entry and table. It now
takes the target prefix in front of its whole name (ADR-0129 D2's own
rename), its references are rewritten where a property names an object,
and a name already claimed is refused up front at the object with the
source rename that clears it.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…ame was measured to do

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 0be162c75dc68d62820d7871f24e4b0af925f0f5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 60e26f78a7bf4cd943c7e11353ddf36604be0ba9 — the merge of head e9c2bc9ad64e981a67690914123336a00c86da70 into base 0be162c75dc68d62820d7871f24e4b0af925f0f5, 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 60e26f78a7bf4cd943c7e11353ddf36604be0ba9 && git checkout 60e26f78a7bf4cd943c7e11353ddf36604be0ba9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0be162c75dc68d62820d7871f24e4b0af925f0f5 e9c2bc9ad64e981a67690914123336a00c86da70 && git checkout -B drift-repro 0be162c75dc68d62820d7871f24e4b0af925f0f5 && git merge --no-ff e9c2bc9ad64e981a67690914123336a00c86da70

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

⚠️ 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 0be162c75dc68d62820d7871f24e4b0af925f0f5 → 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 e9c2bc9ad · 2026-10-14T18:23Z

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 #23511, claim 6173963047, dev report on #23511.

1. Shape

  • Draft, base main. Line 1 Fixes #23511, line 2 Clause-②: no. No other closing keyword.
  • 3 files, +439 / -12. check-governed-merges --pr 23525 reads NOT governed (451 lines).
  • The changeset is a @objectstack/metadata-protocol patch. Its FROM / TO / Also / Unchanged paragraphs match the code.

2. Premises measured

  • H1 held: prefix swap only; the free-text regex covers string values; record keys were never rewritten.
  • H3 held at 222293f60: failed[] carries the D9.9 "Cannot layer object" text.
  • H4 held: the copy gets its own table (pinned).
  • H2 reading: rename. A bare ticket becomes <targetNs>_ticket, ADR-0129 D2's single-underscore prefix (ADR-0028 is Deferred; 0129 D4 governs).
    • A bare name is rewritten only at derived object-reference properties, on an exact value, never through the free-text regex. The regex route was measured to corrupt prose ("A support dupdst_ticket raised by a customer").
    • A collision is refused up front in failed[], at the object's name, with the source rename that clears it.

3. Gates

  • Check-runs on e9c2bc9ad at this act: 15 success, 6 skipped, 16 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 has 4 cases. All 4 are red against a base-built dist and green at head.
  • metadata-protocol: 28397 passed. objectql local: 8101 passed.
  • Ablation on the bare-name branch: 4 failed; the restore is verified.

4. Deviations accepted

5. Out of scope

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 18:45
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 18:45
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 56147a8 Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23511-duplicate-package-unprefixed branch October 14, 2026 19:12
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