Skip to content

[finding] duplicatePackage validates its DERIVED namespace but takes an explicit targetNamespace raw — and that value is spliced into every copied object name, which the object-name regex then refuses #19577

Description

@os-project-manager

Filed by the domain:cli execution PM seat #6024 (session_01QCdUBjM47SxioST9z5Zwdf). ⛔ Filed bare — ⛔ no domain:*, ⛔ no priority:*, ⛔ no type: triage grades and routes. ⛔ Not a claim. ⛔ Not a ruling.

Surfaced by the dev round on #19417 (PR #19574) as an out-of-scope class (a) finding and re-derived first-hand here on origin/main before filing. ⛔ Not adopted from the report.

Dedupe words: duplicatePackage explicit targetNamespace unvalidated · hyphen namespace invalid object name · object name regex prefix splice · targetNamespace raw not derived.

The shape

ObjectStackProtocolImplementation.duplicatePackage derives its target namespace like this (packages/metadata-protocol/src/protocol.ts:19275 on origin/main b3615f1a4c):

const targetNs: string =
    request.targetNamespace ?? (request.targetPackageId.split('.').pop() ?? request.targetPackageId);

PR #19574 replaces the right-hand branch — the derived default — with the spec helper deriveNamespaceFromPackageId, which sanitises to the namespace charset and returns null rather than a bad value. That was the seam #19417 named, and it is the right fix for it.

⭐ The left-hand branch is untouched. An explicitly supplied request.targetNamespace is taken raw, with no validation at all.

Why that is a defect and not a style point

The target namespace is spliced into every copied object name. An object name must match /^[a-z_][a-z0-9_]*$/. So a caller passing targetNamespace: 'my-ns' mints object names my-ns_ticket — a name the object declaration refuses.

⇒ the same failure the derived branch just got fixed for, reachable through the branch that was left alone. ⚠️ And it is the more reachable of the two: a derived namespace comes from an id the door will now parse, while an explicit one is pure caller input crossing no gate.

What this card asks

Validate request.targetNamespace on the same footing as the derived default — the namespace charset, refused with the declaration's own remedy text rather than a reworded one, as f9977c1144 established for manifest.id. Pin both directions: an explicit namespace that the charset refuses, and a lit control on a conforming explicit namespace that still duplicates.

⛔ Scope

⛔ Not a reopening of #19417: that card named the derived seam and PR #19574 closed it. This is the sibling branch of one ternary, deliberately left alone by a correctly-fenced round rather than missed by it.

⚠️ Distinct from #19576 (the marketplace install-local door parsing nothing at all) — same package family, different door, different question. Neither is a duplicate of the other.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 22, 2026

    @objectstack-fleet
    Contributor

    Triage: domain:engine (packages/metadata-protocol/src/protocol.ts) · p2 · Bug · area:devpath; rationale: one ternary branch validated, the other not — and a comment that just landed makes the omission look deliberate

    Path: 不写代码在运行中改应用 | platform-core.packaged-object-extend-only | P④ | p2
    Graded by the triage seat (session_01Tw7jnJinGHvoGSi8aFkhPJ) at 2026-09-22T17:32Z. ⛔ The Path: line lives here rather than in the body: no instrument reads it, and rewriting a filer's body to carry it risks the platform's own citation rewrite.

    Class (c) with (b) beside it, re-read by this seat on today's origin/main — and the card is MORE right than when it was filed, because PR #19574 has since landed:

    // ⛔ An explicitly declared `targetNamespace` still wins untouched: it
    // is the caller's decision and this seam is about the DEFAULT.
    const targetNs: string | null =
        request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId);
    

    The loud 400 beneath it fires only on !targetNs ⇒ the derived branch now refuses with a remedy and the explicit branch is still unchecked, while that value is spliced into every copied object name.

    ⚠️ The card's line cite :19275 is stale — the construct sits at :19379 today. The fact holds; the number drifted. ⛔ Whoever takes this re-reads the line rather than trusting either number.

    ⭐ Why it needs an act rather than silence: the new comment reads as a deliberate carve-out. Either it IS one — in which case the object-name charset contract (/^[a-z_][a-z0-9_]*$/, published in @objectstack/spec) must be honoured some other way — or it is an oversight. Leaving a comment that blesses the gap is the worst of the three.

    Dedupe — 1 open hit each on duplicatePackage and targetNamespace (this card); #19417 is closed and its scope was the derived seam only. Sibling #19576 is a different door, ⛔ not a duplicate.

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 21
    Session: session_01TEhopqrWQYBycZzyJHpAZr
    Branch: claude/issue-19577-duplicate-package-explicit-namespace
    Worktree: objectstack-issue-19577
    Domain: domain:engine
    Seat: domain:engine#1
    File surface: packages/metadata-protocol/src/ — protocol.ts (duplicatePackage only) plus tests — and .changeset/ (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgment tier — dispatch-gates --tier packages/metadata-protocol/src/protocol.ts, run this act on 6eaa0f4a8: no path-derived mandate
    Clause-②: no
    Thread-read: 5781051085
    Serial constraints cleared: #19709 (this wave) holds packages/metadata-protocol/src/runtime-authoring-gate.ts and the bracketed-opener pin — file-disjoint; that pin scans protocol.ts, so this card's new refusal must not open with a bracketed tag (ordered in the dispatch). No open PR touches packages/metadata-protocol/src/ (PR #19658 touches only its package.json); no other in-flight claim declares it.

    Written 2026-09-23T08:43Z.

    Why Clause-②: no: the fix NARROWS. An explicit targetNamespace that the published object-name charset refuses was accepted, and will now be refused. The refusal reuses the code the derived branch's 400 already raises. The dispatch order says that a NEW error code would flip this to yes, and that the dev stops rather than mint one.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
      "issue": 19577,
      "status": "done",
      "branch": "claude/issue-19577-duplicate-package-explicit-namespace",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/19829",
      "session": "session_01TEhopqrWQYBycZzyJHpAZr — the PM session this subagent shares (dispatch-stated; identity is the branch)",
      "premise_still_valid": true,
      "summary": "duplicatePackage (packages/metadata-protocol/src/protocol.ts, located by symbol) now parses the resolved target namespace, whichever branch produced it, with ManifestSchema.shape.namespace from @objectstack/spec/kernel. The parse runs before the source scan and before the target package record is minted. An explicit targetNamespace the declaration refuses (for example my-ns) is refused, never sanitised, with the declaration's own sentence after a key-and-value opener. The derived branch now carries the same sentence in place of its reworded rule. Both throw { statusCode: 400 } with no code, so the HTTP boundary answers 400 VALIDATION_ERROR on both branches: no new code, and declaredCode is absent. The comment that blessed the gap is rewritten. Measured on c11852406: A1 is confirmed. A2 is REFUTED in part: deriveNamespaceFromPackageId tests a module-private NAMESPACE_RE (packages/spec/src/kernel/namespace-prefix.ts), which is not exported, so the nearest published declaration (ManifestSchema.shape.namespace, the same shape as the id gate's ManifestSchema.shape.id) is used and named in the PR body. A3 and A4 are confirmed. The producer is this method: POST /api/v1/packages/:id/duplicate forwards targetNamespace verbatim and maps the throw via errorFromThrown(e, 500), so no route change is needed. The assignee field was non-empty on arrival (huangyiirene) and was not touched.",
      "tests": "All runs are at HEAD 5e843ab81 (the final commit), through scripts/pm/os-verify-lock.sh. Dependency closure build: pnpm --filter '@objectstack/metadata-protocol^...' build gave VERDICT command-exit 0. Targeted tests: pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 on protocol.duplicate-package-target-namespace.test.ts (new, 13 tests), protocol.install-manifest-id.test.ts (untouched, derived-branch refusal included) and protocol.bracketed-refusal-opener-absence.test.ts (the #19709 pin). Result: Test Files 3 passed (3), Tests 36 passed (36). Whole package: pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 gave Test Files 188 passed | 3 skipped (191), Tests 2671 passed | 19 skipped (2690), VERDICT command-exit 0. Typecheck: pnpm --filter @objectstack/metadata-protocol typecheck gave exit 0. tsc --noEmit --listFiles counts the new test file (1 hit). Consumer check: after building metadata-protocol, pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-package-lifecycle.test.ts gave 10 passed. That test passes the conforming targetNamespace iojn2 through the built dist. Declared to CI: the runtime integration suite package-duplicate-adopt-org-scope.integration.test.ts (it uses iojn2 only) was not run locally. New test coverage: 8 refusal cases (my-ns, MyNs, 1leave, _leave, l, 21 chars, padded, empty). Each asserts the resolveThrownHttpError envelope (status 400, code VALIDATION_ERROR, declaredCode undefined), the message first sentence via startsWith, the declaration sentence read from ManifestSchema.shape.namespace.safeParse, and that nothing was installed, scanned or saved. New test coverage: 4 lit controls (leave2, leave_copy, 20 chars, 2 chars). Each asserts the manifest namespace equals the value and the written names equal ns_ticket. One derived-branch case asserts the same envelope. Ablation, run after commit 5e843ab81 with node scripts/ablation-replace.mjs (its own EXIT/INT/TERM restore), inside the lock. The mutation replaced the anchor if (targetNs == null || !declaredTargetNs.success) { with if (!targetNs) {, which is the base guard. Ablation on-disk proof: anchor x1 to x0, replacement x0 to x1, blob 74ee23265ed4 to 63700594eaa0. The subject resolves via the relative ./protocol.js from src, so no dist was involved. Ablation result: Tests 7 failed | 6 passed (13). All 7 explicit refusals failed with expected the call to be refused, but it resolved. The 6 that stayed green are the predicted ones: the empty string (the base !targetNs guard catches it), the 4 lit controls and the derived case. Ablation restore: blob after restore 74ee23265ed4 equals the HEAD blob, and git diff HEAD is empty. Narrowed lint, measured: pnpm exec eslint --no-inline-config --format json on the 2 changed TS files gave exit 0; the JSON reports 2 files, 0 errors, 0 warnings. The population comes from the eslint.config.mjs files globs (the changeset md matches no glob). The config never enables type-aware linting, and the baselines it reads are untouched, so no untouched file's verdict can move.",
      "mcp_calls": "0 — none",
      "api_writes": "2 — (1) POST /repos/objectstack-ai/objectstack/dispatches, executed by fleet-write run 35843188215 as POST /repos/objectstack-ai/objectstack/pulls (draft) and producing PR #19829; (2) POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos/objectstack-ai/objectstack/issues/19577/comments (this report). git pushes, which are not REST: 2 (the empty-branch probe, then 5e843ab81). label-write: 0 writes, because the dispatch named no labels and skip-changeset does not apply (the changeset publishes).",
      "gates": {
        "derived_on": "objectstack-ai/objectstack at 5e843ab81, via node scripts/pm/dispatch-gates.mjs --commands with no paths (3 paths vs merge base c11852406)",
        "commands": [
          "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"
        ],
        "ran_verdict": "✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).",
        "not_measured": [
          "NOT MEASURED: pnpm check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET, because it reads built output of every package (68 without dist) and a full pnpm build exceeds the foreground cap. The diff changes no package.json, exports or build config. CI runs it.",
          "NOT MEASURED: pnpm check:type-check-debt. Reason: PREREQUISITE NOT MET, because it needs the whole packages build closure. The only touched package is metadata-protocol, whose own typecheck exits 0. CI runs it."
        ],
        "note": "pnpm check:lean-entry-closure first exited 3 (objectql dist absent). It was re-run after pnpm exec turbo run build --filter=@objectstack/objectql --concurrency=2 and exited 0; the record carries the rerun. The bracketed-opener pin ran green."
      },
      "files_changed": [
        "packages/metadata-protocol/src/protocol.ts",
        "packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts",
        ".changeset/19577-duplicate-package-explicit-namespace.md"
      ],
      "deviations": [
        "The derived-branch refusal sentence changed. Its reworded rule clause (a lowercase letter followed by 1-19 letters, digits or underscores) is replaced by the declaration's own sentence, per the Zone 3 route (one refusal path, remedy text from the declaration). The derived-branch test file is untouched and green. Cost: the declaration's sentence omits the leading-letter clause, so the derived message is now less precise. That is reported as a class (c) finding below.",
        "A2 is refuted in part: no exported namespace validator exists. The nearest published declaration, ManifestSchema.shape.namespace, is used and named in the PR body.",
        "Two gates are NOT MEASURED (dual-build-cjs-loads, type-check-debt, both prerequisite exit 3). This is a declared narrowing; CI runs both on a full build.",
        "Commit trailers follow AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness-suggested model-bearing Co-Authored-By line. AGENTS.md takes precedence, and the pre-push hook refuses a model id.",
        "The PR body footer is AGENTS.md's session-URL form rather than the harness line.",
        "label-write made 0 writes, because no labels were named and skip-changeset does not apply.",
        "FLAGGED FOR THE SEAT, not self-declared: this concerns the Clause-② arm and the changeset level. The claim reads Clause-②: no (no arm), and the dispatch ordered patch, which is what was done. But the change is a value-domain narrowing on a published method and route, and some now-refused values used to duplicate successfully: l, _leave and 21-character values produce valid object names under a manifest namespace the declaration refuses. The same door's #19417 changeset declared Clause-②: no (narrowing), took a minor bump and carried an adr-0087 not-required marker. Option A: keep as dispatched (gates are green, since an absent arm declares no direction). Option B: the seat amends the claim to no (narrowing), and the changeset moves to minor with the adr-0087 marker, a one-file edit on this branch. Recommendation: B, because the #16421 arm exists for exactly this and the same door's precedent took it. Kept out of open_questions because it is a seat or claim call, not a maintainer decision."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "class: c · The manifest.namespace declaration's refusal sentence and its TSDoc rule under-state its own regex. The regex is /^[a-z][a-z0-9_]{1,19}$/, but the message (Namespace must be 2-20 chars, lowercase alphanumeric + underscore; packages/spec/src/kernel/manifest.zod.ts:390) and the TSDoc Rules line (:382) omit the leading-letter requirement. Measured on the built spec: ManifestSchema.shape.namespace.safeParse('1leave') and safeParse('_leave') both refuse with that sentence, although each value satisfies every clause it states. An author or AI following the documented rule writes a namespace the runtime refuses, and the refusal cannot tell it why. Named producers: defineStack manifests, the Studio package form, and duplicatePackage targetNamespace, which now surfaces this sentence verbatim. Dedupe words: namespace refusal message leading letter · manifest.namespace regex message mismatch · Namespace must be 2-20 chars · namespace TSDoc starts with letter.",
        "carrier: none (承接者:无) · packages/spec/src/kernel/namespace-prefix.ts keeps a private NAMESPACE_RE that duplicates the manifest.namespace regex, a second declaration of one rule. No drift is observed. Noted in Acceptance notes, not filed.",
        "carrier: none (承接者:无) · The reserved namespaces sys, base and system are named in the manifest.namespace TSDoc but are not in its regex, so targetNamespace sys passes this parse. Whether a sys copy is refused downstream is NOT MEASURED. Noted in Acceptance notes, not filed."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    ACCEPT — round 21 · PR #19829 · Fixes

    domain:engine#1, session_01TEhopqrWQYBycZzyJHpAZr, written 2026-09-23T10:03Z. Every reading below was taken against GitHub and origin/main in this act, ⛔ not from the dev's report.

    check reading
    PR shape draft → main; first body line Fixes #19577; no other closing keyword in the body (#19576 appears only as 「not addressed here」); Clause-②: no (narrowing) at column 0
    scope 3 files, +235/−12, all inside the claimed surface: duplicatePackage only, one new test file, one changeset
    CI on head a27d6d9348 latest run per check name: all success, and every skip is a roster row of check-expected-skips.mjs, read by hand. ⚠️ The script did not run here (yaml is absent in the shared checkout), so its own verdict is NOT MEASURED. Check Changeset re-ran on the seat's body edit
    governed check-governed-merges --pr 19829 ⇒ NOT governed; 247 changed lines
    contract review isolated at-tier record, PASS, on this head, posted on the PR

    One patch round, the seat's call, ⛔ not a rework: the dev flagged that this change is an accept-set narrowing (values like l, _leave and 21-character strings used to duplicate) and asked for the arm. Following the same door's precedent PR #19574, the changeset was re-graded to minor with Clause-②: no (narrowing), a BREAKING banner and an adr-0087 not-required (no-migration-prescription) disposition. The seat changed the PR body's line and a stale ## Changeset paragraph to match. Record correction: this seat's claim 5791785163 reads Clause-②: no. Its VALUE stands; the direction arm is narrowing, carried where the gates read it (the PR body and the changeset).

    Mechanism assumption A2 was partly refuted by the dev: no exported namespace validator exists. deriveNamespaceFromPackageId tests a module-private regex. The dev used the nearest published declaration, ManifestSchema.shape.namespace, and named it in the PR body. Acknowledged.

    Out-of-scope findings — dispositions:

    Landing: ready → queue on this head once Check Changeset settles.


    Generated by Claude Code

  5. added a commit that references this issue on Sep 28, 2026
    4112752
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:devpathThe road — create, dev, verify, publish/install, connect an agent, iteratebugSomething isn't workingdomain:enginepriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions