Skip to content

fix(metadata-protocol): duplicatePackage parses an explicit targetNamespace through the manifest namespace declaration - #19829

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19577-duplicate-package-explicit-namespace
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19577-duplicate-package-explicit-namespace

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #19577

Clause-②: no (narrowing)

What changed

ObjectStackProtocolImplementation.duplicatePackage (packages/metadata-protocol/src/protocol.ts, located by symbol) resolved its target namespace as request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId), and the loud 400 fired only on !targetNs. So an explicit targetNamespace crossed no gate. That value is written as the copy's manifest.namespace and spliced into every copied object name as ${targetNs}_${short}, which means targetNamespace: 'my-ns' minted my-ns_ticket, a name the object declaration refuses.

Both branches now pass one parse, before the source scan and before the target package record is minted:

  • The declaration, by reference. ManifestSchema.shape.namespace (exported from @objectstack/spec/kernel, already imported by protocol.ts for the id gate). It is the manifest.namespace rule itself (/^[a-z][a-z0-9_]{1,19}$/, packages/spec/src/kernel/manifest.zod.ts), not a copied regex. The parse input is targetNs ?? '', because the declaration is .optional() and would otherwise pass an absent value.
  • Refused, not sanitised. An explicit value the declaration refuses is refused. It is never rewritten the way the derivation sanitises an id.
  • The declaration's sentence. The refusal opens with the key and the value, then carries the declaration's own issue message verbatim. For example: Invalid package namespace 'my-ns' on + backtick targetNamespace backtick + . Namespace must be 2-20 chars, lowercase alphanumeric + underscore. It becomes the copy's manifest.namespace and the prefix of every copied object name. The derived branch keeps its Cannot derive a package namespace from 'ID'. Pass targetNamespace explicitly. opener. Its reworded rule clause is replaced by the same declaration sentence. Neither message opens with a bracketed tag.
  • No new error code. Both refusals throw { statusCode: 400 } with no code, exactly as the id refusal above them does. An HTTP boundary (resolveThrownHttpError) therefore answers 400 VALIDATION_ERROR on both branches, which is the status-derived code the derived branch already answered.
  • The comment that blessed the gap ("An explicitly declared targetNamespace still wins untouched …") is rewritten to state the new truth: the explicit value still wins over the default, but it is parsed.

Dispatch assumptions, measured

  • A1 (confirmed). On base c11852406 the construct is exactly as described. The ablation below shows the explicit branch resolving for every value the declaration refuses.
  • A2 (refuted in part). There is no exported namespace validator or regex that deriveNamespaceFromPackageId sanitises toward. It tests a module-private NAMESPACE_RE in packages/spec/src/kernel/namespace-prefix.ts, which is byte-identical to the manifest declaration's regex but not exported. The nearest published declaration is ManifestSchema.shape.namespace, and that is what this PR uses. The same shape was used for the id: ManifestSchema.shape.id.
  • A3 (confirmed). The comment is rewritten.
  • A4 (confirmed). No new code is minted. The explicit refusal reuses the derived branch's status-only shape, and the tests assert declaredCode is absent.
  • Producer location. The fix is at the producer. POST /api/v1/packages/:id/duplicate (packages/runtime/src/domains/packages.ts) forwards a string targetNamespace verbatim to this method and maps the throw with errorFromThrown(e, 500). That route therefore answers 400 VALIDATION_ERROR with no route change.

Tests

New file: packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts. The existing protocol.install-manifest-id.test.ts is untouched and still green, derived-branch refusal included.

  • Refusal, 8 cases. Hyphen my-ns, uppercase, leading digit, leading underscore, 1 char, 21 chars, padded ' leave2 ', and ''. Each case asserts:
    • the envelope via resolveThrownHttpError: status 400, code VALIDATION_ERROR, declaredCode undefined;
    • that the message starts with its first sentence;
    • that the message contains the declaration's own issue message, read from ManifestSchema.shape.namespace.safeParse and not retyped;
    • that nothing was installed, scanned or saved.
  • Lit control, 4 cases. leave2, leave_copy, 20 chars and 2 chars. Each still duplicates, manifest.namespace equals the value, and the written object names are exactly ${ns}_ticket.
  • Derived branch, 1 case. Same envelope, and the declaration's sentence follows the targetNamespace remedy.

Runs (all at HEAD 5e843ab81, through scripts/pm/os-verify-lock.sh):

  • pnpm --filter '@objectstack/metadata-protocol^...' build: VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.duplicate-package-target-namespace.test.ts src/protocol.install-manifest-id.test.ts src/protocol.bracketed-refusal-opener-absence.test.ts: Test Files 3 passed (3), Tests 36 passed (36). The bracketed-opener pin is green.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 (whole package): Test Files 188 passed | 3 skipped (191), Tests 2671 passed | 19 skipped (2690), VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. tsc --noEmit --listFiles includes the new test file (1 hit).
  • Consumer check: pnpm --filter @objectstack/metadata-protocol build && pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-package-lifecycle.test.ts gave 10 passed. That test is a downstream caller passing the conforming targetNamespace: 'iojn2' through the built dist. The runtime integration suite package-duplicate-adopt-org-scope.integration.test.ts also uses iojn2 only; it is declared to CI and was not run here.

Ablation (one-shot, after commit 5e843ab81). Tool: node scripts/ablation-replace.mjs, which restores itself on EXIT/INT/TERM, wrapped in the verify lock. It replaced the anchor if (targetNs == null || !declaredTargetNs.success) { with if (!targetNs) {, which is the base guard, so the new parse is removed.

  • On disk: anchor x1 → x0, replacement x0 → x1, blob 74ee23265ed463700594eaa0. The subject resolves via the relative ./protocol.js, so it runs from src/ and needed no rebuild.
  • Result: Tests 7 failed | 6 passed (13). All 7 explicit refusal cases failed with expected the call to be refused, but it resolved.
  • The 6 that stayed green are what was predicted: '' (the base !targetNs guard already caught it), the 4 lit controls, and the derived case.
  • Restore: blob after restore 74ee23265ed4 equals the HEAD blob, and git diff HEAD is empty.

Gates

node scripts/pm/dispatch-gates.mjs --commands was run on HEAD 5e843ab81: 61 commands, each run with its exit captured before any pipe. --ran verdict: ✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).

  • pnpm check:lean-entry-closure first answered exit 3 (PREREQUISITE NOT MET: objectql dist absent). It was re-run after pnpm exec turbo run build --filter=@objectstack/objectql --concurrency=2 and gave exit 0 (2 published condition(s) measured from a real load). The record carries the rerun.
  • NOT MEASURED: pnpm check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET, since it reads the built output of every package (68 lack dist here). A full pnpm build does not fit the foreground cap, and this diff changes no package.json, exports or build config. CI runs it on a fresh full build.
  • NOT MEASURED: pnpm check:type-check-debt. Reason: PREREQUISITE NOT MET, since it needs the whole packages build closure. The only package touched is @objectstack/metadata-protocol, whose own typecheck exits 0.
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-changeset-no-major.mjs --self-test :: exit 0
node scripts/check-ci-filter-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-keyed-text-bounds.mjs :: exit 0
node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0
node scripts/check-platform-object-tenancy-census.mjs :: exit 0
node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
node scripts/check-registry-log-declared.mjs :: exit 0
node scripts/check-registry-log-declared.mjs --self-test :: exit 0
node scripts/check-rest-log-spy-declared.mjs :: exit 0
node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0
node scripts/check-system-context-census.mjs :: exit 0
node scripts/check-system-context-census.mjs --self-test :: exit 0
node scripts/check-undeclared-dep-imports.mjs :: exit 0
node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 3
pnpm check:durability-log-level :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:filter-alias-parity :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:objectui-changeset :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 3
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0

Narrowed lint (measured). pnpm exec eslint --no-inline-config --format json was run over the two changed TS files: exit 0, and the JSON reports 2 files, 0 errors, 0 warnings.

  • ① Population, from eslint.config.mjs: files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] plus the packages/**/*.{ts,…} blocks. The changeset .md is in no files glob, so these two files are the whole lintable part of the diff.
  • ② File count: 2, read from the JSON output.
  • ③ Invariance: the config never enables type-aware linting (no parserOptions.project, no typed rules; stated at eslint.config.mjs around line 327). The baselines it reads (scripts/slot-lookup-baseline.json, scripts/query-options-erasure-baseline.json) are untouched. So this diff cannot move any verdict on an untouched file.

Changeset

.changeset/19577-duplicate-package-explicit-namespace.md: minor for @objectstack/metadata-protocol, declaring Clause-②: no (narrowing), a BREAKING for callers banner and an adr-0087: not-required (no-migration-prescription) disposition. It was re-graded from patch in a patch round on the seat's call, following the same door's precedent PR #19574. The claim's Clause-② VALUE (no) is unchanged; the (narrowing) arm carries the direction the changeset gates read.

Acceptance notes


Generated by Claude Code

…espace through the manifest namespace declaration

An explicit `targetNamespace` was taken raw and spliced into every copied
object name, while the derived default already had to satisfy the namespace
charset. Both branches now pass one `ManifestSchema.shape.namespace` parse
before anything is scanned or minted, refusing with the declaration's own
sentence and the status-derived 400 VALIDATION_ERROR the derived branch
already answered.

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

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 1cacfe4a425dc0c2cdd4eb5d0088d5b3f5a16c92packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1cacfe4a425dc0c2cdd4eb5d0088d5b3f5a16c92

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

…owing (minor)

An explicit targetNamespace outside the manifest.namespace declaration used
to be accepted verbatim and is now refused, so the changeset declares
Clause-② no (narrowing), ships minor, carries the BREAKING banner naming
the refused values, and states its ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a27d6d9348277ae8e8e276712eed7a391e389962

Rendered by an isolated at-tier reviewer subagent that was fed the card, its rulings and this PR only, and adopted by the domain:engine#1 seat. Two commits over merge-base c118524: 5e843ab81 (the fix) and a27d6d9 (the changeset re-grade). Three files, +235/-12. No governed surface is touched.

① Derived judgments

  • An explicit targetNamespace outside the declaration is now refused (it was accepted verbatim) → RIGHT. The parse is the declaration by reference, ManifestSchema.shape.namespace.safeParse(targetNs ?? ''). On origin/main that declaration is a single regex with no refine, transform or trim. So every value now refused is one the published declaration refuses, by construction, and each of the 8 refusal fixtures is regex-refused. The REST route forwards targetNamespace verbatim and untrimmed, so the whitespace case is reachable and correctly refused.
  • No declaration-accepted value is refused → RIGHT. The only refusal outside the parse is targetNs == null: no explicit value, and a derivation that returned null. Base already refused that case. The reserved names sys/base/system are TSDoc-only, not in the regex, and stay accepted on both sides, which is correct for a door that refuses exactly what the declaration refuses.
  • Derived branch: accept set unchanged; refusal changes only in its message → RIGHT. deriveNamespaceFromPackageId returns only values its private NAMESPACE_RE admits, and that regex is byte-identical to the declaration's, so the new parse can never refuse a derived value. Nothing pinned the old wording.
  • Refusal position → unchanged: after the manifest.id gate, and before any engine.find, installPackage or manifest mint. The tests pin that none of them is called on a refusal.
  • No new error code → RIGHT. Both throws are a statusCode: 400 Error with no code. resolveThrownHttpError maps that to 400 VALIDATION_ERROR, declaredCode absent, so the route answers the same envelope on both branches. The success response shape is untouched.
  • Exports / public surface → no export is added, removed or renamed.
  • Tests assert the envelope → yes: status, code, declaredCode, the opener, the rule sentence read off the declaration, and no side effects. Four lit controls assert the namespace and the written object names.

② Semver level

  • minor + Clause-②: no (narrowing) → matches the diff. The change is a value-domain narrowing of a published method and route: l, _leave and 21-character values used to duplicate to legal object names and are now refused. no is correct, since there is no new key and no new error code. check-changeset-no-major.mjs treats a declared narrowing as BREAKING and ships it minor during the launch window. The same door's precedent (2306a75ecb, PR fix(metadata-protocol)!: the protocol install primitive parses manifest.id, and the duplicate door parses its target id (#19417) #19574) took the same shape.
  • BREAKING banner → present. It names what is refused, states that conforming and omitted values duplicate as before, and carries an "If you are refused" remedy.
  • adr-0087 not-required (no-migration-prescription) → TRUE. The body carries no FROM → TO prescription, and no spec key, export, stored row or request field is renamed or reshaped. A refused value was already forbidden by manifest.namespace, and because the door refuses rather than sanitises, no mechanical rewrite exists to register.
  • The PR body's Clause-② line agrees with the changeset's → yes. The reviewer found one stale ## Changeset paragraph (still reading patch); the seat corrected it in the description before landing, with no code change.

③ Boundary flags

  • The declaration's sentence under-states its regex, and the derived-branch message now inherits the gap → acceptable. The card asked for the declaration's own remedy text, read by reference, so the spec-side fix (manifest.namespace refuses a leading digit or underscore, but its refusal sentence and TSDoc rule never say so — 1leave / _leave satisfy every stated clause and are refused #19831, filed) propagates here with no edit.
  • The private NAMESPACE_RE duplicates the declaration's regex → acceptable; it is byte-identical today, and that identity is what keeps the derived accept set unchanged.
  • The reserved names pass the parse → acceptable. That is unchanged from base, and refusing them would refuse values the declaration accepts.
  • The clause-② arm / changeset level → resolved by a27d6d9.
  • Two gate families NOT MEASURED locally → acceptable: the CI jobs that carry them are success on this head.

Implemented-by: claude/issue-19577-duplicate-package-explicit-namespace
Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

VERDICT: PASS


Generated by Claude Code

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

1 participant