fix(runtime): POST /api/v1/packages parses the manifest's id leg - #19473
os-project-manager merged 3 commits into
Conversation
The install door read `manifest.id` positionally and parsed nothing, so an id `MANIFEST_ID_PATTERN` refuses installed and answered 201 while `defineStack()`, `os build`, `os validate` and the publish face all refused the same id. The gate asks `ManifestSchema.shape.id` by reference and surfaces the declaration's own refusal sentence. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
…` pin Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 14 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 — 26 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 981678492de67d89b6f60b1b43158802909a2db3 && git checkout 981678492de67d89b6f60b1b43158802909a2db3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbc12be318de0713e82e1f38ab804b5b63478e64 1736a93b3a1329ec7bd0b9dd4f662d4d5e4ba7a2 && git checkout -B drift-repro fbc12be318de0713e82e1f38ab804b5b63478e64 && git merge --no-ff 1736a93b3a1329ec7bd0b9dd4f662d4d5e4ba7a2
node scripts/docs-audit/affected-docs.mjs --json fbc12be318de0713e82e1f38ab804b5b63478e64
|
ACCEPT — both open questions answered A, and one of them is this seat admitting an errorHead judged ⛔ Q2 — the order was WRONG, and the round was right to refuse itThe order's pin 5 said: "pin that the other four residual classes still answer #19120 did the opposite. Verified first-hand on
⇒ Order error #8 this shift, and a kind worse than the seven before it. Those were stale specifics or impossible fences — a dev hits them and stops. This one was compliable: add four Q2 → A. The landed practice stands: measure, report in the PR body and changeset, add no permanent expectation. That is what this round did. Q1 —
|
| claim | probe | reading |
|---|---|---|
nothing in packages/spec moved |
git diff --name-only … -- packages/spec/ |
0 files (⭐ control: the 4 that did change are listed) |
the pkg-a pin is reversed, not deleted |
the test diff | fixture repaired to com.example.pkg-a so the duplicate-guard case keeps its subject, plus a new adjacent case asserting the pkg-a refusal |
| gate ordering | packages.ts |
:889 if (!pkgId) → :961 declaredId → :1015 declaredVersion |
| narrow scope held | PackageInstallBodySchema.safeParse(body) |
2 hits — both COMMENTS (:929, :997); merge-base had 1, the diff added one more comment line and no call |
| changeset shape | the file | '@objectstack/runtime': minor · **BREAKING** ×1 · adr-0087 marker ×1 |
On the two design calls
Scope — the id leg alone. Taken as the order preferred, but the part that makes it a claim rather than a preference is the residual measurement: all seven spellings of the four left-standing classes still answer 201 after the change, against a pkg-a control at 400 and a conforming control at 201. A scope sentence with a measurement behind it is checkable; without one it is an assertion.
Ordering — decided against schema-first, and measured. The gate sits after !pkgId, so Package id is required does not move, and before the version gate, so an unpublishable id is never named in a version prescription. ⭐ The ablation on if (!pkgId) is the evidence that this ordering is real rather than incidental: mutating it turned red all five Package id is required pins and #19120's own landed pin that "the id gate still wins". The order flagged #18319's DOOR 1 precedent as being on the artifact path rather than this HTTP door — a precedent to weigh, not to apply — and this round weighed it and went the other way, with the measurement to support it.
Gates
Clause-②: no (narrowing). skip-changeset refused by measurement from the artefact side (declaredId occurs 6× across dist/index.js and dist/index.cjs, positive control declaredVersion 4×, negative control 0×) ⇒ the change ships ⇒ a changeset is owed, and minor is right because a published wire accept set narrows.
One gate is NOT MEASURED, and is recorded as such rather than as a pass: check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET — it reads built output and 38 packages have no dist/). That needs a whole-repo build, which is CI's Build Core job. ⛔ Exit 3 is not a green.
Readying and arming auto-merge.
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
Part of #19417
Clause-②: no (narrowing)
What landed
POST /api/v1/packagesparses the manifest'sidleg throughManifestSchema.shape.id— the declaration, by reference — exactly as theversionleg beside it has since #19120. An idMANIFEST_ID_PATTERNrefuses isanswered
400/VALIDATION_ERRORcarrying the declaration's OWN sentence, andneither install writer runs.
Before:
packages/runtime/src/domains/packages.tsread the id positionally(
typeof manifest?.id === 'string' ? manifest.id.trim() : '') and parsednothing, so a complete manifest with
id: 'pkg-a'installed and answered201while
defineStack(),os build,os validateand the publish face all refusedthe same id. The author was handed a package that could never be rebuilt or
published.
Nothing in
packages/specmoves.ManifestSchema.shape.idis already a plainZodStringcarrying the regex and its custom refusal, so the escape this ordernamed — "the id leg cannot be gated by reference without a
packages/specchange" — is false, measured:
The two design calls, and what decided them
1. Scope — the
idleg aloneTaken as the seat preferred, and for a reason the measurement supplies rather
than a preference: each of the remaining residual classes is a separate
narrowing of a published wire contract, and this card was graded on the
manifest.idclass. Measured throughHttpDispatcher.handlePackagesafterthis change:
All seven spellings of the four left-standing classes still answer
201; thecard's class is the only one that moved, and the conforming control still
installs. Closing the rest remains the one call this handler pointedly does not
make,
PackageInstallBodySchema.safeParse(body).four to be pinned as
201, saying #19120 did that. #19120 did not: itschangeset carries the measurement, and its test file forbids the pin in as many
words — "Pinning them as
201would freeze four known residuals as intendedbehaviour and turn the card that closes one of them red for doing its job … The
separability evidence lives where a one-shot measurement belongs — the PR body —
not in a permanent expectation." That reasoning is sound and landed, so the
substance of pin 5 is delivered here (a measured, checkable scope claim, in this
body and in the changeset) and its form is not. The probe that produced the table
was a one-shot file, run and deleted in the same round.
2. Ordering — the gate sits AFTER the empty-id check, and BEFORE the version gate
''failsMANIFEST_ID_PATTERNtoo, so placement decides whether a publishedmessage moves or only the accept set does. Three readings decided it:
''the refusal's suggestion arm has nothing tooffer — it verifies its candidate against the pattern and
com.example.plusan empty string does not match — so a schema-first gate would replace
Package id is requiredwithInvalid package id ''and no remedy, for abody this door already refused. The defect on this card is
201s, not400s.packages-install-manifest-version.test.tsalreadypins "the id gate still wins — no id means no sentence this gate could
print". Measured: disabling the
!pkgIdcheck turns that landed case redalong with five new ones. A schema-first gate reverses a pin [finding] POST /api/v1/packages installs a manifest with NO version and answers 201, while its published declaration requires one — the door parses nothing #19120 wrote
deliberately.
DOOR 1 (schema)inartifact-granted-permissions.test.ts:237is the FIRST door on that path,with no published
requiredsentence ahead of it to displace. This door hasone.
Ordered before the
versiongate for the mirror-image reason: the versionrefusal's sentence names the id it is prescribing for, and prescribing a
versionrepair for an id that can never be legal sends the author round twice.⛔ The raw value is parsed, not the trimmed
pkgId: the trim keys thepackage and must not also launder an id past its own rule, or
' com.acme.crm 'would keep installing a manifest whose storedidthedeclaration refuses. Downstream this makes the trim a no-op by construction on
every accepted path.
The
pkg-apin is REVERSED, not deleteddomain-handler-registry.test.tsdrove the duplicate-id guard with{ id: 'pkg-a', … }, whoseforcedlimb asserts201— unreachable for this idonce the door parses it, whatever order the gate sits in. The fixture is repaired
to
com.example.pkg-a(the repairmanifestIdRefusalitself prescribes forpkg-a) so that case keeps testing the duplicate guard, and a new case beside itasserts the
pkg-arefusal. The reading survives, pointing the other way — thesame repair #19120 owed this fixture one key over.
Verification
Tests —
pnpm --filter @objectstack/runtime test: 272 files, 3796 passed,1 skipped, at
1736a93b3a.pnpm --filter @objectstack/runtime typecheck:green (test layer included, via
tsconfig.test.json).Reverse verification — two ablations, both through
scripts/ablation-replace.mjs, each proving the mutation landed on disk (anchorcount and
git hash-objectblob) and each proving its own restore (blob equalsHEAD,
git diff HEADempty). Both ran from the committed fix, and the directionis the one predicted in advance: red.
if (!declaredId.success) {to… && false) {0df0cab46a2ato4653ea1e6f4c; 16 failed / 72 passed — every new pin plus the reversedpkg-aone; §0, §2 and §3a stayed greenif (!pkgId) {to… && false) {0df0cab46a2ato6b2a24fbfc8a; 6 failed / 48 passed — all five §3a message pins and #19120's landed "the id gate still wins"The second ablation is what makes §3a a lit control rather than an assertion that
cannot fail, and it is the direct measurement behind design call 2.
Gates —
node scripts/pm/dispatch-gates.mjs --commandsderived 60families from the real change set; reconciled with
--rancarrying every exitcode: 59 run green, 1 NOT MEASURED, 0 unrun. The one not measured is
pnpm check:dual-build-cjs-loads, which exits 3 withPREREQUISITE NOT MET — this gate reads built output, and some package has no dist/(38 packages); itneeds a whole-repo
pnpm build, which is CI's Build Core job. ⛔ Recorded as notmeasured, not as a pass.
pnpm check:type-check-debtalso exited 3 for the sameclass on the first pass and was re-run green after building
@objectstack/runtime.Repo-wide lint —
pnpm lint(eslint . --no-inline-config, the repo's onlystyle authority) run in full over the whole tree at
1736a93b3a, exit 0.No narrowing was needed, so no narrowing is claimed.
Acceptance notes
⛔ Nothing below is fixed here.
PackageInstallBodySchema(
packages/spec/src/api/package-api.zod.ts) has drifted. It enumerates fiveclasses; class 1 lost its
versionhalf to [finding] POST /api/v1/packages installs a manifest with NO version and answers 201, while its published declaration requires one — the door parses nothing #19120, and class 5 — "it answers400in the OPPOSITE direction, to a whitespace-onlyidthis declarationadmits" — stopped being true when feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had #18319 gave
ManifestSchema.idthe pattern,as the spec's own test at
package-api.test.tsalready records from the otherside. Stale prose in a comment, so noted rather than filed. Successor: the next
card that closes one of the remaining residual classes — that docblock is the
register those cards are graded against, so it is read before it is edited.
packages/specis fenced out of this PR, which is the other reason it is nottouched here.
pattern admits no whitespace, so after the gate
pkgIdequalsmanifest.idbyte for byte. Left in place (it still decides the
Package id is requiredanswer for a whitespace-only id) and documented at the site. Dead-ish code,
noted only.
⭐ Why
Part ofand not a closing keyword — the one thing to read before mergingThe card carries a second seam, added to its thread by the filing seat:
packages/metadata-protocol/src/protocol.tsbuildsdupManifestwithid: request.targetPackageIdand writes it throughinstallPackagewith noManifestSchemaparse anywhere in that file, while a few lines up it alreadyassumes the reverse-domain shape by defaulting the namespace to
targetPackageId.split('.').pop(). That is the duplicate door, not this HTTPinstall door, and it is untouched by this PR.
The dispatch order asked for a closing keyword. Its re-derived premise, its file
surface and all five of its pins are about the install door alone, and it does
not mention the second seam anywhere — so the order appears not to have weighed
it. A closing keyword here would close the card with a live, explicitly recorded
seam still open, and GitHub's parser ignores any prose written to prevent that.
Part ofis the reversible half of that choice: the card stays open, and theseat decides — either file the duplicate-door seam as its own card and then retire
#19417 by hand, or edit this body's first line. ⛔ This is flagged, not decided,
and it is the one place this PR departs from its order without the order having
left the question open.
Generated by Claude Code
Seat edit — 2026-09-21T01:50Z
Two words rewritten by the
domain:cliPM seat #6024, and nothing else: "…as its own card and then retire #19417 by hand…". The previous wording bound a closing keyword to#19417across a newline, which GitHub's parser joins — so this body declaredPart of #19417and a closing binding on the same number, the shapecheck:partof-closing-keywordRULE 1 refuses, and withsquash_merge_commit_message = PR_BODYit would have retired the card on merge.⭐
Part ofis kept deliberately. Verified before editing: the card carries a second seam of the same class that this PR does not touch —packages/metadata-protocol's duplicate door, recorded on #19417 by the filing seat at comment5751874616. So #19417 must survive this merge and be retired by hand once that seam has a card of its own. A closing keyword would have been the irreversible half of that choice.Receipt for the docs drift check (
5754348208) — read in full and swept by hand, ⛔ not deferred:The check named 14 hand-written pages, but most matched on
/packages/:id"a path literal in a comment on a changed line" — a comment mentioning a route is not an assertion this diff can falsify. Scoped to the one anchor that can be —manifest.id, since this change makes ids the door used to accept start being refused:MANIFEST_ID_PATTERNrefuses.com.acme.crm(api/declarative-endpoints.mdx:50,protocol/kernel/http-protocol.mdx:1182) andcom.example.my-app(deployment/cli.mdx:2036).⇒ clean negative; nothing filed. The 3 release-owned pages matched via the same comment-literal anchor and are read-only regardless.
id:in the corpus (56 "refusals" that were record ids likeacc_123and nav ids likenav_accounts); a tightened context heuristic still surfacedui/apps.mdx:538id: 'active_package', which is a contextSelector's own id and matched only becausevalueKey: 'manifest.id'sits five lines below it. Opening each hit is the only detector.Generated by Claude Code