fix(metadata-protocol)!: the protocol install primitive parses manifest.id, and the duplicate door parses its target id (#19417) - #19574
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 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
|
Fixes #19417
Clause-②: no (narrowing)
Fixes, notPart of, and the reason is measured rather than assumed: the landing record5754746826held this card open for exactly one thing — thepackages/metadata-protocolseam,protocol.tsbuildingdupManifestwithid: request.targetPackageIdand writing it throughinstallPackagewith noManifestSchemaparse. 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'sManifestSchema.shape.id.safeParsegate, the refusal pins, and the REVERSEDdomain-handler-registry.test.tspin ([#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/mainat branch creation) and3128642fd60833d130844364f1b5e6c872efafb5(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_PATTERNrefuses, through this door? Enforcing here narrows a live door whose docblock says it serves packages that never take thedefineStackpath.Measured answer: no — no first-party code mints a refused id through this door. The instrument and its controls:
protocol.installPackage, whole repoobjectql/src/engine.ts,service-package/src/index.ts) — litpackages/runtime/src/domains/packages.ts(POST /packages)ManifestSchema.shape.id.safeParsegate #19473 landed sits above theprotocolSvc.installPackage(...)call in the same branch, unconditionally ⇒ only conforming ids reach the primitive from there!pkgId, theversionleg) are present in the same readduplicatePackage(same file)request.targetPackageId, caller-supplied: its only non-test caller isPOST /packages/:id/duplicate, which reads it from the request body and checks only that it is non-emptyduplicatePackagenon-test callers = 1, enumeratedos initstampscom.example.+manifestIdSlug(name), andmanifestIdSlugforces a letter-initial segment — conforming by construction. The shipped example manifests arecom.example.crm/.showcase/.tododefineStackoccurrence counts inexamples/are non-zero — lit0cf2d6644bdb96a9a6784ef801ee6a60a5306bd8, cloned to read it)-copyappended — conforming whenever the source is. Nothing auto-generates a refused idtargetPackageIdoccurrence count in objectui is non-zero, every hit openedpackages/studio/srcandpackages/setup/srcdo not exist in this tree, and apackages/*/srcpathspec is dead againstgit 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.installPackagespread the request intoanyand handed it toSchemaRegistry.installPackagewith a secondas any.grep ManifestSchemaover 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 throughprotocol.installPackagewhen the protocol service resolves. So its gate protects that one caller and nothing else:duplicatePackageis 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:installPackageparses the rawmanifest.idthroughManifestSchema.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 carriesstatusCode: 400so an HTTP boundary answers 400 rather than the 500 an unannotated throw earns (resolveThrownHttpError).statusCodeis the spelling this file already uses for its 404.duplicatePackageparses its target id at the top of the method. It has to be there, not only ininstallPackage: the manifest write below sits inside a deliberately best-effortcatch {}, so a refusal raised only there would be swallowed and the caller would readsuccess: trueon 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 thePOST /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 istargetPackageId, the path the caller actually wrote.id.split('.').pop()to the spec helperderiveNamespaceFromPackageIdthatinstallPackagealready used. This is not cosmetic: the target namespace is spliced into every copied object name asnamespace + '_' + 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-copyappended — therefore derivedleave-copyand mintedleave-copy_ticket, a name the object declaration refuses. The helper answersleave_copy. The source side is the same rule read backwards: the prefix those rows actually carry is the oneinstallPackagestamped, 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 explicittargetNamespacestill wins untouched; when neither an explicit nor a derivable namespace exists, the door refuses loudly namingtargetNamespaceas 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:idat all, and a whitespace-padded conforming id. Each asserts the 400 tag, the message identical tomanifestIdRefusalitself (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 durablepublish.com.example.crm,com.example.my-erpandorg.apache.supersetstill install, registry called exactly once with the id intact.com.example.my_erpstill containscom.example.my-erp, the mechanical repair the declaration verifies before offering.registry.installPackage,engine.findandsaveMetaItemall uncalled), against a lit control where a conforming target duplicates rows and all.leave_copyand writesleave_copy_ticket; an explicittargetNamespacewins; 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 againstHEAD(no baregit checkout --). The subject resolves fromsrchere — the suite imports./protocol.jsinside its own package — sodistis not on this resolution path.installPackageid gatebe9dd23ad9c8→8981aea29d39, restored tobe9dd23ad9c8,git diff HEADemptysplit('.').pop()back fortargetNsbe9dd23ad9c8→613737784401, restored tobe9dd23ad9c8,git diff HEADemptyGates — 61 commands, harvested at this head
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat3128642fd6(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-closurefirst exited 3 (PREREQUISITE NOT MET — it loads built entry points and@objectstack/objectqlhad nodist); afterturbo run build --filter=@objectstack/objectqlit 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 nodist/. That needs a whole-repo build, which is CI'sBuild Core.check:type-check-debt— exit 3, same class:--re-measurerefuses to record a number against an unbuilt closure, since an unresolved import invents TS2307/TS7006 and erases the real debt.lint.ymlbuilds the closure before this step.Plus, beyond the harvest:
pnpm --filter '@objectstack/metadata-protocol^...' buildgreen; the package's full suite green (185 files, 2,645 tests, 0 failures — no existing pin moved);pnpm --filter @objectstack/metadata-protocol typecheckgreen, with--listFilesconfirming the new test file is in the program; the three@objectstack/objectqlsuites that drive a REAL protocol instance green (29 tests); and the repo-wide unionpnpm lint(eslint . --no-inline-config) green at3128642fd6— the union, so no narrowing had to be proven.Scope held, and what is deliberately left standing
idleg alone.InstallPackageRequestSchema/ManifestSchemaare still not parsed whole here; the residual classes the HTTP door's own docblock records are untouched and are each their own narrowing.packages/specdoes not move, and was never opened: the declaration was already right, and the order fenced it out.SchemaRegistry.installPackage/ObjectQL.registerAppdirectly 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
packages/app-shell/src/views/studio-design/packages-io.tsexportsPACKAGE_ID_RE = /^[a-z][a-z0-9_.-]*(\.[a-z0-9_-]+)+$/, which admits underscores and digit-initial segments;MANIFEST_ID_PATTERNis/^[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 acceptscom.example.my_erpand 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.duplicatePackage's explicittargetNamespaceis still unvalidated. An explicitly passedmy-nsis spliced into object names asmy-ns_x, which the object declaration refuses. This change only aligned the DERIVED default, which is the seam the order named.reassignOrphanedMetadatareadstargetPackageIdwithout 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