Skip to content

fix(metadata-protocol)!: the protocol install primitive parses manifest.id, and the duplicate door parses its target id (#19417) - #19574

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-19417-protocol-install-door-parses-manifest-id
Sep 21, 2026
Merged

os-project-manager merged 1 commit into
mainfrom
claude/issue-19417-protocol-install-door-parses-manifest-id

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #19417

Clause-②: no (narrowing)

Fixes, not Part of, and the reason is measured rather than assumed: the landing record 5754746826 held this card open for exactly one thing — the packages/metadata-protocol seam, protocol.ts building dupManifest with id: request.targetPackageId and writing it through installPackage with no ManifestSchema parse. That seam is closed here. Every other ask on the card already landed with #19473 and is verified present on this branch's base: the HTTP door's ManifestSchema.shape.id.safeParse gate, the refusal pins, and the REVERSED domain-handler-registry.test.ts pin ([#19417] POST /packages refuses 'pkg-a' — the id the pattern refuses (the REVERSED pin)). Nothing on the card is left standing, so merging it should close it.

Diff measured between b3615f1a4cd7f3ff59ff0548530daa042627f732 (the merge base, origin/main at branch creation) and 3128642fd60833d130844364f1b5e6c872efafb5 (head). 3 files, +465 / -3.

⏳ The measurement the order asked for FIRST — and it is not what the seam note feared

The order's blocking question: does the platform itself mint package ids that MANIFEST_ID_PATTERN refuses, through this door? Enforcing here narrows a live door whose docblock says it serves packages that never take the defineStack path.

Measured answer: no — no first-party code mints a refused id through this door. The instrument and its controls:

probe reading control
non-test callers of protocol.installPackage, whole repo 2, both opened the same instrument returns the registry-direct call sites it must not confuse them with (objectql/src/engine.ts, service-package/src/index.ts) — lit
caller 1 — packages/runtime/src/domains/packages.ts (POST /packages) the ManifestSchema.shape.id.safeParse gate #19473 landed sits above the protocolSvc.installPackage(...) call in the same branch, unconditionally ⇒ only conforming ids reach the primitive from there the branch's other gates (!pkgId, the version leg) are present in the same read
caller 2 — duplicatePackage (same file) id is request.targetPackageId, caller-supplied: its only non-test caller is POST /packages/:id/duplicate, which reads it from the request body and checks only that it is non-empty duplicatePackage non-test callers = 1, enumerated
first-party id MINTING sites os init stamps com.example. + manifestIdSlug(name), and manifestIdSlug forces a letter-initial segment — conforming by construction. The shipped example manifests are com.example.crm / .showcase / .todo defineStack occurrence counts in examples/ are non-zero — lit
the Studio UI (objectui 0cf2d6644bdb96a9a6784ef801ee6a60a5306bd8, cloned to read it) both duplicate dialogs prefill the source id with -copy appended — conforming whenever the source is. Nothing auto-generates a refused id targetPackageId occurrence count in objectui is non-zero, every hit opened

⚠️ The seat's two failed probes are not repeated here: packages/studio/src and packages/setup/src do not exist in this tree, and a packages/*/src pathspec is dead against git grep. Every reading above names a real path and carries a control that hit.

⇒ the narrowing refuses caller input, never a value the platform produces. No grandfather clause is invented, and nothing is routed around.

The fix, and why it is on the primitive

ObjectStackProtocolImplementation.installPackage spread the request into any and handed it to SchemaRegistry.installPackage with a second as any. grep ManifestSchema over its 22,686 lines returned exactly 2 hits, both comments — no parse anywhere in the file.

⭐ #19473 is not this door, and it is also not unrelated — measured, because both readings matter. It landed in packages/runtime/src/domains/packages.ts, and that HTTP door does route through protocol.installPackage when the protocol service resolves. So its gate protects that one caller and nothing else: duplicatePackage is a second caller and an embedder holding the protocol object is a third. Gating a door buys that door; this gate is on the method every caller passes.

Three changes, all in packages/metadata-protocol/src/protocol.ts:

  1. installPackage parses the raw manifest.id through ManifestSchema.shape.id — by reference, never a copy of the grammar — ahead of the spread, the version default and the namespace derivation. The refusal is the declaration's own sentence (manifestIdRefusal), surfaced rather than reworded, and the throw carries statusCode: 400 so an HTTP boundary answers 400 rather than the 500 an unannotated throw earns (resolveThrownHttpError). statusCode is the spelling this file already uses for its 404.
  2. duplicatePackage parses its target id at the top of the method. It has to be there, not only in installPackage: the manifest write below sits inside a deliberately best-effort catch {}, so a refusal raised only there would be swallowed and the caller would read success: true on a package with no manifest row — a silent partial state, strictly worse than the status quo this card set out to close. The position also honours the POST /packages/:id/duplicate 对一个启动中的代码包返回 success:false, copiedCount:0, failedCount:0(空 base)—— ADR-0070 D4「复制成可写 base」对代码包是否本就该拷贝其对象? #14451 rule already on this door: refuse before the mint, or the empty shell is left behind. The key named is targetPackageId, the path the caller actually wrote.
  3. One assumption, one implementation. Both namespace derivations on the duplicate path moved from a raw id.split('.').pop() to the spec helper deriveNamespaceFromPackageId that installPackage already used. This is not cosmetic: the target namespace is spliced into every copied object name as namespace + '_' + short, and an object name is /^[a-z_][a-z0-9_]*$/ (packages/spec/src/data/object.zod.ts). The Studio's own default duplicate id — the source id with -copy appended — therefore derived leave-copy and minted leave-copy_ticket, a name the object declaration refuses. The helper answers leave_copy. The source side is the same rule read backwards: the prefix those rows actually carry is the one installPackage stamped, so matching them with the raw split found nothing and the copy landed under the SOURCE's names — the collision the re-namespacing exists to prevent. An explicit targetNamespace still wins untouched; when neither an explicit nor a derivable namespace exists, the door refuses loudly naming targetNamespace as the remedy instead of renaming rows with an empty prefix.

Both directions pinned

packages/metadata-protocol/src/protocol.install-manifest-id.test.ts, 18 cases, all passing:

  • refusal — four refused ids (a bare word, an underscore inside a segment, the empty string, a digit-initial segment), a manifest with no id at all, and a whitespace-padded conforming id. Each asserts the 400 tag, the message identical to manifestIdRefusal itself (a pin that retyped the sentence would go green on a reworded fourth sentence for one rule), and that neither writer ran — not the in-memory registry, not the durable publish.
  • lit control — com.example.crm, com.example.my-erp and org.apache.superset still install, registry called exactly once with the id intact.
  • the repair survives — the refusal for com.example.my_erp still contains com.example.my-erp, the mechanical repair the declaration verifies before offering.
  • duplicate door — the same four refused targets are refused with nothing minted and nothing scanned (registry.installPackage, engine.find and saveMetaItem all uncalled), against a lit control where a conforming target duplicates rows and all.
  • namespace — the Studio default yields leave_copy and writes leave_copy_ticket; an explicit targetNamespace wins; an underivable one refuses naming the remedy.

Reverse verification — direction predicted BEFORE running, both legs restored and proven

Run through scripts/ablation-replace.mjs, which proves the mutation reached disk by anchor count and blob hash and proves the restore against HEAD (no bare git checkout --). The subject resolves from src here — the suite imports ./protocol.js inside its own package — so dist is not on this resolution path.

ablation predicted measured
A — delete the installPackage id gate the installPackage refusal pins turn RED; the lit controls and every duplicate-door pin stay GREEN 7 failed / 11 passed. Exactly the installPackage arm, including the repair pin I had not counted; both control groups green. Blob be9dd23ad9c8 → 8981aea29d39, restored to be9dd23ad9c8, git diff HEAD empty
B — put the raw split('.').pop() back for targetNs only the two namespace pins turn RED 2 failed / 16 passed, exactly those two. Blob be9dd23ad9c8 → 613737784401, restored to be9dd23ad9c8, git diff HEAD empty

Gates — 61 commands, harvested at this head

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 3128642fd6 (the script derived the change set itself from the merge base; ⛔ not a hand-written path list). 58 green, 0 red, 3 NOT MEASURED — then one of the three was converted by building its prerequisite, leaving 59 green / 2 NOT MEASURED.

Green, by name: check-adr-0087-registration (+ self-test), check-changeset-no-major (+ self-test), check-ci-filter-parity, check-closing-keyword-parity (+ self-test), check-comment-mask-adoption (+ self-test), check-comment-mask-corpus, check-empty-changeset (+ self-test), check-keyed-text-bounds (+ self-test), check-platform-object-tenancy-census (+ self-test), check-plugin-teardown-shape (+ self-test), check-registry-log-declared (+ self-test), check-rest-log-spy-declared (+ self-test), check-system-context-census (+ self-test), check-undeclared-dep-imports (+ self-test), docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/release-rehearsal-clone --self-test, spec check:duration-unit-keys, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:dispatcher-error-vocabulary, check:doc-authoring, check:driver-memory-census, check:dts-closure, check:durability-log-level, check:engine-double-contract, check:filter-alias-parity, check:gitlink-declared, check:issue-citations, check:lean-entry-closure, check:logger-receiver-detach, check:nul-bytes, check:objectql-double-limit, check:objectui-changeset, check:org-identifier, check:page-declaration-shape, check:pm-changeset-deadline-census, check:published-files, check:query-options-erasure, check:refd-timer-probe, check:slot-lookup, check:sourcemap-no-sources-content, check:test-source-alias, check:tier-file-adoption, check:type-check-coverage, check:watch-hint-literal, check:where-matcher.

check:lean-entry-closure first exited 3 (PREREQUISITE NOT MET — it loads built entry points and @objectstack/objectql had no dist); after turbo run build --filter=@objectstack/objectql it measured green: 2 published conditions, 15 packages, admitted set held exactly.

NOT MEASURED, and recorded as such — neither a pass nor a failure:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 68 packages have no dist/. That needs a whole-repo build, which is CI's Build Core.
  • check:type-check-debt — exit 3, same class: --re-measure refuses to record a number against an unbuilt closure, since an unresolved import invents TS2307/TS7006 and erases the real debt. lint.yml builds the closure before this step.

Plus, beyond the harvest: pnpm --filter '@objectstack/metadata-protocol^...' build green; the package's full suite green (185 files, 2,645 tests, 0 failures — no existing pin moved); pnpm --filter @objectstack/metadata-protocol typecheck green, with --listFiles confirming the new test file is in the program; the three @objectstack/objectql suites that drive a REAL protocol instance green (29 tests); and the repo-wide union pnpm lint (eslint . --no-inline-config) green at 3128642fd6 — the union, so no narrowing had to be proven.

Scope held, and what is deliberately left standing

  • The id leg alone. InstallPackageRequestSchema / ManifestSchema are still not parsed whole here; the residual classes the HTTP door's own docblock records are untouched and are each their own narrowing.
  • packages/spec does not move, and was never opened: the declaration was already right, and the order fenced it out.
  • Boot-time and in-process installs are unaffected — they reach SchemaRegistry.installPackage / ObjectQL.registerApp directly and never pass this primitive. Versionless and namespace-less manifests still install; their defaults simply run behind the id gate instead of ahead of it.

Acceptance notes — observed, ⛔ not fixed here

  1. objectui declares the same rule, looser. packages/app-shell/src/views/studio-design/packages-io.ts exports PACKAGE_ID_RE = /^[a-z][a-z0-9_.-]*(\.[a-z0-9_-]+)+$/, which admits underscores and digit-initial segments; MANIFEST_ID_PATTERN is /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/ and its own TSDoc says underscores are NOT admitted. Both the package-create dialog and the duplicate dialog validate against the looser copy, so the Studio accepts com.example.my_erp and the server has refused it since fix(runtime): POST /api/v1/packages parses the manifest's id leg #19473 — a second declaration of one rule, in the sibling repo. Reported for a card of its own; ⛔ not touched from here.
  2. duplicatePackage's explicit targetNamespace is still unvalidated. An explicitly passed my-ns is spliced into object names as my-ns_x, which the object declaration refuses. This change only aligned the DERIVED default, which is the seam the order named.
  3. reassignOrphanedMetadata reads targetPackageId without parsing it, the same positional read one method over. Left alone deliberately: it rebinds rows to an EXISTING package rather than minting one, so it is a different question about a different door.

Generated by Claude Code

…rimitive (#19417)

`ObjectStackProtocolImplementation.installPackage` spread the request into
`any` and handed it to `SchemaRegistry.installPackage` with a second `as any`,
so an id `MANIFEST_ID_PATTERN` refuses installed and persisted while
`defineStack()`, `os build`, `os validate` and the publish face all refused the
same id. #19473 closed the HTTP door, which is one CALLER of this primitive;
`duplicatePackage` is a second and an embedder is a third.

The gate asks the declaration by reference (`ManifestSchema.shape.id`) and
surfaces its own sentence (`manifestIdRefusal`) rather than rewording it, ahead
of every write and every derivation. `duplicatePackage` parses its target id at
the top of the method, because its manifest write sits inside a best-effort
`catch {}` that would otherwise swallow the refusal and report success.

Both namespace derivations on the duplicate path move from a raw
`id.split('.').pop()` to the spec helper `deriveNamespaceFromPackageId`, the
one `installPackage` already used: the target namespace is spliced into every
copied object name, and the Studio's default `<sourceId>-copy` derived
`leave-copy`, minting names the object declaration refuses.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 5 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx (via manifest.id (literal, a string literal in installPackage))
  • content/docs/deployment/cli.mdx (via manifest.id (literal, a string literal in installPackage))
  • content/docs/deployment/publish-and-preview.mdx (via manifest.id (literal, a string literal in installPackage))
  • content/docs/getting-started/quick-reference.mdx (via manifest.id (literal, a string literal in installPackage))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/protocol/kernel/http-protocol.mdx (via manifest.id (literal, a string literal in installPackage))
  • content/docs/ui/apps.mdx (via manifest.id (literal, a string literal in installPackage))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via installPackage (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))

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 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 b3615f1a4cd7f3ff59ff0548530daa042627f732 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json b3615f1a4cd7f3ff59ff0548530daa042627f732

⚠️ 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 b3615f1a4cd7f3ff59ff0548530daa042627f732 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-project-manager
os-project-manager marked this pull request as ready for review September 21, 2026 09:59
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 2306a75 Sep 21, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-19417-protocol-install-door-parses-manifest-id branch September 21, 2026 10:14
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…espace through the manifest namespace declaration (objectstack-ai#19829)

Fixes objectstack-ai#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 `74ee23265ed4` →
`63700594eaa0`. 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.

```text
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 objectstack-ai#19574. The claim's `Clause-②` VALUE (`no`) is unchanged; the
`(narrowing)` arm carries the direction the changeset gates read.

## Acceptance notes

- **The declaration's sentence under-states its own regex.**
`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')` and
`safeParse('_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.ts` keeps a private
`NAMESPACE_RE` that duplicates the manifest declaration's regex. It is a
second declaration of one rule; no drift is observed today. Carrier:
none.
- Reserved namespaces (`sys`, `base`, `system`) are named in the
`manifest.namespace` TSDoc but are not part of its regex, so
`targetNamespace: 'sys'` passes this parse. This PR validates the
charset declaration only. Whether a `sys` copy is refused downstream is
NOT MEASURED. Carrier: none.
- objectstack-ai#19576 (install-local door) is not addressed here. The objectstack-ai#19417
derived-seam work is untouched apart from its refusal sentence now
coming from the declaration.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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