fix(metadata-protocol): duplicatePackage parses an explicit targetNamespace through the manifest namespace declaration - #19829
Conversation
…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>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
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 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 |
…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>
Contract reviewServed-tier: Rendered by an isolated at-tier reviewer subagent that was fed the card, its rulings and this PR only, and adopted by the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19577
Clause-②: no (narrowing)
What changed
ObjectStackProtocolImplementation.duplicatePackage(packages/metadata-protocol/src/protocol.ts, located by symbol) resolved its target namespace asrequest.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId), and the loud400fired only on!targetNs. So an explicittargetNamespacecrossed no gate. That value is written as the copy'smanifest.namespaceand spliced into every copied object name as${targetNs}_${short}, which meanstargetNamespace: 'my-ns'mintedmy-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:
ManifestSchema.shape.namespace(exported from@objectstack/spec/kernel, already imported byprotocol.tsfor the id gate). It is themanifest.namespacerule itself (/^[a-z][a-z0-9_]{1,19}$/,packages/spec/src/kernel/manifest.zod.ts), not a copied regex. The parse input istargetNs ?? '', because the declaration is.optional()and would otherwise pass an absent value.Invalid package namespace 'my-ns' on+ backticktargetNamespacebacktick +. 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 itsCannot 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.{ statusCode: 400 }with nocode, exactly as the id refusal above them does. An HTTP boundary (resolveThrownHttpError) therefore answers400 VALIDATION_ERRORon both branches, which is the status-derived code the derived branch already answered.targetNamespacestill wins untouched …") is rewritten to state the new truth: the explicit value still wins over the default, but it is parsed.Dispatch assumptions, measured
c11852406the construct is exactly as described. The ablation below shows the explicit branch resolving for every value the declaration refuses.deriveNamespaceFromPackageIdsanitises toward. It tests a module-privateNAMESPACE_REinpackages/spec/src/kernel/namespace-prefix.ts, which is byte-identical to the manifest declaration's regex but not exported. The nearest published declaration isManifestSchema.shape.namespace, and that is what this PR uses. The same shape was used for the id:ManifestSchema.shape.id.declaredCodeis absent.POST /api/v1/packages/:id/duplicate(packages/runtime/src/domains/packages.ts) forwards a stringtargetNamespaceverbatim to this method and maps the throw witherrorFromThrown(e, 500). That route therefore answers400 VALIDATION_ERRORwith no route change.Tests
New file:
packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts. The existingprotocol.install-manifest-id.test.tsis untouched and still green, derived-branch refusal included.my-ns, uppercase, leading digit, leading underscore, 1 char, 21 chars, padded' leave2 ', and''. Each case asserts:resolveThrownHttpError:status 400,code VALIDATION_ERROR,declaredCodeundefined;ManifestSchema.shape.namespace.safeParseand not retyped;leave2,leave_copy, 20 chars and 2 chars. Each still duplicates,manifest.namespaceequals the value, and the written object names are exactly${ns}_ticket.targetNamespaceremedy.Runs (all at HEAD
5e843ab81, throughscripts/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 --listFilesincludes the new test file (1 hit).pnpm --filter @objectstack/metadata-protocol build && pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-package-lifecycle.test.tsgave10 passed. That test is a downstream caller passing the conformingtargetNamespace: 'iojn2'through the built dist. The runtime integration suitepackage-duplicate-adopt-org-scope.integration.test.tsalso usesiojn2only; 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 anchorif (targetNs == null || !declaredTargetNs.success) {withif (!targetNs) {, which is the base guard, so the new parse is removed.74ee23265ed4→63700594eaa0. The subject resolves via the relative./protocol.js, so it runs fromsrc/and needed no rebuild.Tests 7 failed | 6 passed (13). All 7 explicit refusal cases failed withexpected the call to be refused, but it resolved.''(the base!targetNsguard already caught it), the 4 lit controls, and the derived case.74ee23265ed4equals the HEAD blob, andgit diff HEADis empty.Gates
node scripts/pm/dispatch-gates.mjs --commandswas run on HEAD5e843ab81: 61 commands, each run with its exit captured before any pipe.--ranverdict:✓ 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-closurefirst answered exit 3 (PREREQUISITE NOT MET: objectql dist absent). It was re-run afterpnpm exec turbo run build --filter=@objectstack/objectql --concurrency=2and gave exit 0 (2 published condition(s) measured from a real load). The record carries the rerun.pnpm check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET, since it reads the built output of every package (68 lack dist here). A fullpnpm builddoes not fit the foreground cap, and this diff changes nopackage.json,exportsor build config. CI runs it on a fresh full build.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 owntypecheckexits 0.Narrowed lint (measured).
pnpm exec eslint --no-inline-config --format jsonwas run over the two changed TS files: exit 0, and the JSON reports 2 files, 0 errors, 0 warnings.eslint.config.mjs:files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']plus thepackages/**/*.{ts,…}blocks. The changeset.mdis in nofilesglob, so these two files are the whole lintable part of the diff.parserOptions.project, no typed rules; stated ateslint.config.mjsaround 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:minorfor@objectstack/metadata-protocol, declaringClause-②: no (narrowing), a BREAKING for callers banner and anadr-0087: not-required (no-migration-prescription)disposition. It was re-graded frompatchin a patch round on the seat's call, following the same door's precedent PR #19574. The claim'sClause-②VALUE (no) is unchanged; the(narrowing)arm carries the direction the changeset gates read.Acceptance notes
ManifestSchema.shape.namespace's message,Namespace must be 2-20 chars, lowercase alphanumeric + underscore, and its TSDoc rule (2-20 characters, lowercase letters, digits, and underscores only) both omit "starts with a lowercase letter". Measured:safeParse('1leave')andsafeParse('_leave')refuse with that sentence, although both values satisfy every clause of it. This PR surfaces the sentence verbatim, as the id precedent requires, so the explicit-refusal message inherits the gap. The derived-branch message used to spell the leading-letter clause itself. That is a spec-side fix outside this card's file surface, and it is reported to the seat as a finding.packages/spec/src/kernel/namespace-prefix.tskeeps a privateNAMESPACE_REthat duplicates the manifest declaration's regex. It is a second declaration of one rule; no drift is observed today. Carrier: none.sys,base,system) are named in themanifest.namespaceTSDoc but are not part of its regex, sotargetNamespace: 'sys'passes this parse. This PR validates the charset declaration only. Whether asyscopy is refused downstream is NOT MEASURED. Carrier: none.manifest.idpositionally and never parses the body throughManifestSchema— POST /packages answers 201 to ids thatMANIFEST_ID_PATTERN(spec,defineStack,os build, the publish face) refuses #19417 derived-seam work is untouched apart from its refusal sentence now coming from the declaration.Generated by Claude Code