Repository navigation
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
The install door's residual docblock, its pinning test and the changeset
transcribed the duplicate-id drive in domain-handler-registry.test.ts as
{ id: 'pkg-a', name: 'A', version: '1.0.0' }. That drive stopped posting
that body at PR #19473, which made the door parse the manifest's id leg
and repaired the fixture's id to com.example.pkg-a. The old body is the
REVERSED pin beside it and is answered 400. It had been filed as a 201
residual under clause 1b.
Read at the merge base fdeeea0 and at origin/main 2c1011b (the file
is byte-identical at both): the drive posts
{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }, answered 409 and
then 201 on ?overwrite=true. The declaration refuses it on `type` alone.
Clause 1b's "both door drives above" is true of that body, so 1b is
unchanged.
- package-api.zod.ts: the drives paragraph quotes the real body and
credits both repairs, the version to PR #19326 and the id to PR #19473.
- package-api.test.ts: DOOR_DRIVE_REGISTRY transcribes the real body.
The control that rested on the stale id now asserts that the registry
drive parses once `type` is added, like the conflict drive. The old
`pkg-a` reading is kept, pointed at the old body: it is refused on the
id alone.
- changeset: the drives sentence names the body the drive posts.
Prose and test only. No schema, accept set, export or runtime change.
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…rence three exported constants (objectstack-ai#19478) Part of objectstack-ai#18697 — construction round **R1 of 2 (phase 1 only)**. This round does not close the card; R2 does. Branch: `claude/issue-18697-version-grammar-canon-phase1` Clause-②: yes (widening) Reason: the criterion has two limbs and this round hits the second one. The ACCEPT SET does not move — every regex written here is byte-identical to the literal it replaces, verified per carrier by sha256 over the extracted literal (G1 `0dc7272048754554`, G2 `f0503f4f0c703a40`, G3 `3e3cb2710d62909a`, each hash equal across its whole group and equal to the constant), so nothing an author can write changes. But the PUBLIC SURFACE grows: three new exported constants on `@objectstack/spec/kernel`, landing as three rows in `packages/spec/api-surface/kernel.json` at :157, :381 and :382. A new exported symbol is `yes` on its own, and that second limb is what this declares. The ruling's `Clause-②: yes` therefore already holds for R1; R2 carries its own accept-set widening on top of it. ## What this does Eight in-repo carriers of "the version of a package or plugin" each spelled a version regex out as a literal of their own. Three accept sets written eight times, still growing on their own: three of the eight were published schema declarations with no parse caller at all. A ninth carrier of the same concept, `PackageManifestSchema.version`, spelled no regex at all — it is a bare `z.string()` and is deliberately left unconstrained by this round. Each now references the constant carrying the pattern it already enforced. `@objectstack/spec/kernel` gains three exported patterns (`packages/spec/src/kernel/version-grammar.ts`, a new file): | constant | grammar | |:--|:--| | `MAJOR_MINOR_PATCH_VERSION_PATTERN` | three numeric segments and nothing else | | `SEMVER_SHAPED_VERSION_PATTERN` | plus an optional prerelease and an optional build suffix, identifiers in either ASCII case | | `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` | the same, suffix identifiers restricted to lowercase ASCII | 10 carriers → 3 referenced declarations + 1 deliberately unconstrained. ## Per-carrier before → after, with the byte-identity assertion Every "before" literal was extracted from the file BY LINE WITH A REQUIRED MARKER on that line (a moved line throws rather than reading the wrong thing) and hashed. Within each group every hash is equal, and the constant's own literal hashes to the same value. **G1 — `MAJOR_MINOR_PATCH_VERSION_PATTERN`, sha256/16 `0dc7272048754554`, 17 bytes** ``` /^\d+\.\d+\.\d+$/ ``` | carrier | line at `fbc12be3` | line on this head | after | |:--|:--|:--|:--| | `packages/spec/src/kernel/manifest.zod.ts` `ManifestSchema.version` | 414 | **415** | `z.string().regex(MAJOR_MINOR_PATCH_VERSION_PATTERN)` | | `packages/spec/src/kernel/metadata-plugin.zod.ts` `MetadataPluginManifestSchema.version` | 649 | **650** | same | | `packages/spec/src/kernel/plugin-registry.zod.ts` `PluginRegistryEntrySchema.version` | 158 | **159** | same | | `packages/spec/src/kernel/plugin-validator.zod.ts` `PluginMetadataSchema.version` | 144 | **145** | same | | `packages/runtime/src/domains/packages.ts` `PATCH /api/v1/packages/:id` | 1680 | **1770** | `!MAJOR_MINOR_PATCH_VERSION_PATTERN.test(patch.version)` | **G2 — `SEMVER_SHAPED_VERSION_PATTERN`, sha256/16 `f0503f4f0c703a40`, 54 bytes** ``` /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/ ``` | carrier | line at `fbc12be3` | line on this head | after | |:--|:--|:--|:--| | `packages/spec/src/kernel/plugin.zod.ts` `PluginSchema.version` | 212 | **216** | `z.string().regex(SEMVER_SHAPED_VERSION_PATTERN)` | | `packages/core/src/plugin-loader.ts` `isSemverShapedVersion` | 501 | **505** | `return SEMVER_SHAPED_VERSION_PATTERN.test(version)` | **G3 — `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN`, sha256/16 `3e3cb2710d62909a`, 48 bytes** ``` /^\d+\.\d+\.\d+(-[a-z0-9.-]+)?(\+[a-z0-9.-]+)?$/ ``` | carrier | line at `fbc12be3` | line on this head | after | |:--|:--|:--|:--| | `packages/spec/src/marketplace/package-version.zod.ts` `PackageVersionSchema.version` | 146 | **147** | `.regex(SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN)` | **G4 — untouched, on purpose.** `packages/spec/src/marketplace/package-version.zod.ts:81` on this head (`:80` at `fbc12be3`) — `PackageManifestSchema.version` — keeps its bare `z.string()`. Giving it a grammar here would be an accept-set move and would break this round's premise. **Tenth carrier, out of reach from here.** `objectui` `PackageFormDialog.tsx:39` / `:227` is a second repository and gets its own card at this PR's ACCEPT. ## Why the fences held - **No `.describe()` text, refusal string or JSON Schema `pattern` moved.** The constant is the regex only; each carrier keeps its own description. Moving the prose is phase 2's job, because the prose only becomes true when the grammar moves. - **Nothing was exported from the ROOT entry.** The constants are on `./kernel`; `marketplace/package-version.zod.ts` references `SEMVER_SHAPED_LOWERCASE_VERSION_PATTERN` cross-entry via `../kernel/version-grammar`, the precedent at `marketplace/marketplace.zod.ts:4`. `packages/spec/api-surface/root.json` is untouched, so PR objectstack-ai#19373's hold on that file is intact. - **No `packages/spec/api-surface/*.json` was hand-edited**, and `packages/spec/CHANGELOG.md` and `packages/spec/package.json` were never opened. - **The objectstack-ai#16365 record in `plugin.zod.ts` is intact.** One sentence in it became false — the one saying this key's regex is `PluginLoader.isSemverShapedVersion`'s spelling character for character — and it is corrected minimally, in place: the two now reference one declaration and can no longer drift apart. The same correction is made to that method's own docblock in `plugin-loader.ts`, where "Change one spelling and you must change both" no longer describes anything. Nothing about phase 2 is announced in either. ## Regenerated artifacts — gains only, on the allow-listed shards `pnpm --filter @objectstack/spec check:generated` named exactly two stale artifacts out of fifteen; both were regenerated with their own generators, never by hand. ``` M packages/spec/api-surface/kernel.json (+3, -0) M packages/spec/export-origins/kernel.json (+3, -0) ``` That is the whole of `git status` after regeneration. `api-surface/root.json`, every `automation.json`, `dropped-refinements.baseline.json`, `declaration-map/*` and `json-schema.manifest/*` all came back byte-unchanged — which is itself the neutrality reading: a pure literal-to-constant collapse produces the identical Zod schema, so a moved `dropped-refinements` line would have been evidence the refactor was not accept-set neutral. `check:authorable-surface` and `check:declaration-map` pass with no regeneration at all. The six added rows are the three constant names in the two kernel shards, and nothing is removed. ## The neutrality proof, and the instrument R2 will move **Not one existing test expectation was edited.** `packages/spec/src/kernel/plugin.test.ts` (which pins eight forbidden-by-SemVer forms as ACCEPTED), `packages/spec/src/kernel/manifest.test.ts` and `packages/core/src/plugin-loader.test.ts` all pass unchanged — that is the proof this cannot move a verdict. New: `packages/spec/src/kernel/version-grammar.test.ts`, describe block **`version grammars — accept-set pin`** — a 3 × 12 matrix pinning each constant's verdict on `1.2.3`, `2.0.0-beta.1`, `1.0.0-Beta.1`, `1.0.0+20230101`, `01.1.1`, `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0+.`, `v1.0.0`, `1.0`, `latest` and the empty string, plus a source-bytes pin per constant, a no-stateful-flag assertion, and the strict-containment ordering (G1 inside G3 inside G2). **Two ablations, each mutated through an anchor that had to hit, each proved on disk, each restored byte-identically (`blob == HEAD`, `git diff HEAD` empty):** 1. Widening `MAJOR_MINOR_PATCH_VERSION_PATTERN` by an optional `-beta` group turns `manifest.test.ts > ManifestSchema > Basic Properties > should enforce semantic versioning` **red** (1 failed / 65 passed). That is the carrier wiring proved live: `ManifestSchema.version` really reads the constant. 2. Widening it by an optional `-ABLATED` group turns the new pin's **source-bytes** assertion red while the 3 × 12 matrix stays green — the witness strings do not cover that shape. Reported because it is the honest reading of what each half of the pin buys: the matrix pins verdicts, the bytes pin catches an equivalent-looking rewrite the witnesses cannot see. Both halves earn their place. Cross-package wiring is visible in the built output too: `packages/core/dist/index.js` and `packages/runtime/dist/index.js` both reference the imported constant. ## Verification | what | command | result | |:--|:--|:--| | build closure | `turbo run build --filter=@objectstack/runtime... --filter=@objectstack/core... --filter=@objectstack/spec... --concurrency=2` | 30/30 tasks successful | | spec suite | `pnpm --filter @objectstack/spec test` | 507 files, 14837 tests passed | | core suite | `pnpm --filter @objectstack/core test` | 51 files, 1321 tests passed | | runtime suite | `pnpm --filter @objectstack/runtime test` | 271 files, 3761 passed / 1 skipped | | typecheck | `turbo run typecheck` for spec, core, runtime | 32/32 tasks successful | | derived gates | `node scripts/pm/dispatch-gates.mjs --commands` then `--ran` | **89 derived, 86 run green, 3 NOT MEASURED, 0 UNRUN** | The three NOT MEASURED are `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`, each exiting **3 — PREREQUISITE NOT MET**, all three for the same reason: they read built output for the whole workspace and only the spec/core/runtime closure was built here. Neither red nor green; nothing was measured. CI builds the full closure and will answer them. A fourth, `check-plugin-teardown-shape.mjs --self-test`, also exited 3 on first run — its positive-control fixture is pinned to a commit this shallow clone could not reach; after `git fetch --depth=200` of that object it re-ran at **exit 0, 48 cases pass**. ## Enqueue: C5 fired, it was correct, and the declaration was corrected Measured, not predicted: ``` node scripts/pm/check-widening-tells.mjs --declaration no --diff PR.DIFF -> exit 4 ✗ T3 packages/spec/api-surface/kernel.json:157 — a new row in a published entry point's export listing ✗ T3 packages/spec/api-surface/kernel.json:381 — a new row in a published entry point's export listing ✗ T3 packages/spec/api-surface/kernel.json:382 — a new row in a published entry point's export listing ``` This PR was first pushed declaring `Clause-②: no`, on the reading that an accept-set-neutral collapse is not a clause-② change. That reading answered one conjunct of two. T3 was right: the public surface really does grow by three exports, and the lane's criterion is 「放宽接受集**或扩大公开面**的卡,不论多小,即条款②」 — a new exported symbol is `yes` on its own, whatever the accept set does. The author escalated rather than flipping the declaration, and the `domain:spec` seat re-declared `Clause-②: yes (widening)` in correction comment 5754511685, superseding its earlier correction. **The question is closed.** The instrument was not weakened and no card was filed against it — the literals this diff removed were inline regexes inside schemas, never on the public surface, so T3 measured exactly what it is defined to measure. The accept-set half of the claim is untouched by any of this and is still proved above, byte for byte. ## Contract review of record (row C6) `domain:spec` owes an at-tier contract review on **every** round it delivers, `Clause-②: yes` **or** `no`, and until that record exists this PR is not landable. The record is written by an isolated `CONTRACT_REVIEW_TIER` reviewer, not by the author. Its `Implemented-by:` names `claude/issue-18697-version-grammar-canon-phase1`. ## Changeset `.changeset/18697-version-grammar-canon-phase1.md` — `@objectstack/spec: minor` (new exported public constants), `@objectstack/core: patch`, `@objectstack/runtime: patch`. No BREAKING banner: that belongs to R2. ## Base Branched from `origin/main` `fbc12be318de0713e82e1f38ab804b5b63478e64`; the dispatch order read `72eeabd3`, which had already moved when the worktree was created.⚠️ **This head carries a base merge.** PR objectstack-ai#19473 landed on `origin/main` and touched `packages/runtime/src/domains/packages.ts` (+96/−8) — carrier objectstack-ai#9 of this diff — so the branch went `dirty` and `origin/main` was merged in at `32b5831c4e48ac9b1626322cf185b286a09073aa`. ⇒ **the base every line number in this body is measured against is `32b5831c`, not `fbc12be3`**,⚠️ **and every pointer moved, not only `packages.ts`.** Each carrier file gained an `import … from './version-grammar'` line, so each carrier sits one line lower than it did at `fbc12be3`; `plugin.zod.ts` and `plugin-loader.ts` moved +4 because their comment blocks grew as well, and `packages.ts` moved `1680 → 1770` (the merge plus the resolved comment block). The tables above now carry BOTH columns, every cell verified line-by-line against this head with a required-substring check. An earlier revision of this body put the `fbc12be3` numbers in a column headed `line` beside one headed `after`, which resolved to the wrong line on the head that ships. `origin/main` has since reached `8015dc85` (one commit, objectstack-ai#19471), which is ⛔ **not** an ancestor of this head and touches none of the 14 files here — `git diff --stat 32b5831 8015dc8` over the 14 paths is empty. The queue rebuilds on current `main` regardless. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…est.id`, and the duplicate door parses its target id (objectstack-ai#19417) (objectstack-ai#19574) Fixes objectstack-ai#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 objectstack-ai#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 (`[objectstack-ai#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 objectstack-ai#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. ⭐ **objectstack-ai#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 objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf)_ Co-authored-by: Claude <noreply@anthropic.com>
…d `version` half and open `type` half (objectstack-ai#19935) Fixes objectstack-ai#19327 Clause-②: no `PackageInstallBodySchema`'s docblock in `packages/spec/src/api/package-api.zod.ts` lists the bodies `POST /api/v1/packages` answers `201` to while the declaration refuses them. Its clause 1 recorded "a manifest missing `type` and/or `version`" as ONE class. PR objectstack-ai#19326 made the door parse `ManifestSchema.shape.version` by reference, so only the `type` half of that clause is still true. This PR splits the clause. The diff is the docblock, one spec test file and one changeset. No schema, accept set, export or runtime code moves, and residual classes 2 to 5 are untouched. ## Both halves re-measured at `origin/main` `fdeeea0cc9`, before any edit Both halves read exactly as the card says. | half | body sent to the door | status | `error.code` | installed | |---|---|---|---|---| | control | wrapped, well-formed | 201 | none | yes | | `version` | wrapped, no `version` | **400** | `VALIDATION_ERROR` | no | | `version` | bare, no `version` | **400** | `VALIDATION_ERROR` | no | | `type` | wrapped, no `type` | **201** | none | yes | | `type` | bare, no `type` | **201** | none | yes | **How it was driven.** I used a one-shot probe, deleted right after the reading and never committed. It was a vitest file at `packages/runtime/src/domains/probe-19327-oneshot.test.ts` with the harness of `packages-install-manifest-version.test.ts`: a real `HttpDispatcher`, spied protocol and registry install writers, and `OS_HOME` redirected. It called `handlePackages('', 'POST', body, {}, admin)` once per row and printed status, code and whether either writer was called. It ran in one invocation with the existing drives that the docblock's parenthesis «both door drives above» points at: ``` bash scripts/pm/os-verify-lock.sh -c "pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 src/domains/probe-19327-oneshot.test.ts src/domains/packages-install-manifest-version.test.ts src/domain-handler-registry.test.ts src/package-door-namespace-conflict-code.test.ts" Test Files 4 passed (4) Tests 82 passed (82) os-verify-lock: VERDICT command-exit 0 ``` - **The `type` half, from both existing drives.** Each drive posts a manifest with no `type`, and each asserts that the door answers `201`: - `package-door-namespace-conflict-code.test.ts` posts `{ id, name: id, namespace, version: '1.0.0' }`. - The duplicate-id case in `domain-handler-registry.test.ts` posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }` (`:600`): `409` first, then `201` on `?overwrite=true`. The body `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` has been that file's REVERSED pin since PR objectstack-ai#19473 and is answered `400` (`:622-623`), so it is not a residual. - **The `version` half, from objectstack-ai#19326's door test.** §1 of `packages/runtime/src/domains/packages-install-manifest-version.test.ts` pins `400` + `VALIDATION_ERROR` + neither writer called, on both body forms. It is **cited, not duplicated**. ## What changed **`packages/spec/src/api/package-api.zod.ts` (docblock only)** - **Clause 1 is split:** - **1a, missing `version`:** marked CLOSED, naming PR objectstack-ai#19326 and the door-side pin. - **1b, missing `type`:** still OPEN, answered `201`, with «both door drives above». - The labels 1a/1b match the table in objectstack-ai#19326's own body, so the two records read the same way. - **The count stays true.** "five classes" remains correct because class 1 is still open through its `type` half. The count sentence now says «class 1 in its `type` half only, since PR objectstack-ai#19326». Numbering 1 to 5 is unchanged, so no citation of clauses 2 to 5 moves. - **In-place fix of the drives paragraph (same defect class).** The paragraph above the list quoted the registry drive as `{ id: 'pkg-a', name: 'A' }` and said «the second carries no `version` either». That drive was repaired twice: PR objectstack-ai#19326 gave it a `version`, and PR objectstack-ai#19473 replaced its id `pkg-a`, which `MANIFEST_ID_PATTERN` refuses. At the merge base `fdeeea0cc9` and at `origin/main` it posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`. The paragraph is clause 1b's antecedent, so left alone it would have filed a no-longer-posted body under the open residual. It now quotes the body the drive posts, says the declaration refuses both drives on `type` alone and the door answers both `201`, credits both repairs, and says the door answers the old `pkg-a` body `400`. **`packages/spec/src/api/package-api.test.ts` (the pin of the clause)** - `DOOR_DRIVE_REGISTRY` now transcribes the body the drive posts: `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }`. Its comment credits both repairs, and its `it` title drops "no `version`". - The control that rested on the stale id is now «the missing `type` is what decides it, for BOTH drives — the registry drive's old id is refused on its own». It asserts that the registry drive parses once `type` is added, as the conflict drive does. The old `pkg-a` reading is kept, pointed at the old body: `{ ...registryKeysCompleted, id: 'pkg-a' }` is refused on the id alone, and the green parse just above it is its lit control. - The residual list is hoisted to `DOOR_201_RESIDUALS`, and the existing "the door answers 201 to all of them anyway" assertion is unchanged over it. - **New pin:** «✅ clause 1a is CLOSED — every body in the live residual carries a `version` the declaration accepts». It checks each residual's manifest with `ManifestSchema.shape.version`. Its lit control is that `undefined` is refused. It cites the door-side pin rather than repeating it, because this package cannot import the door. **`.changeset/19327-install-door-residual-split.md`: `patch` for `@objectstack/spec`** A changeset is owed, not `skip-changeset`, because `files[]` ships `src/**/*.zod.ts` and the docblock is also emitted into the built declarations. After the build, the new clause text is in `dist/api/index.d.ts` and `dist/api/index.d.mts`, and the old spelling "and/or `version`" hits 0 across `dist` and `src`. The bump is `patch` because it is a text correction in a released package, with no API change. Note that npm `@objectstack/spec@17.4.0` has no `PackageInstallBodySchema` at all: grep 0, with `PackageInstallRequestSchema` = 4 as the control in the same file. The stale clause has therefore not shipped yet. The next release would be the first to carry it. ## Verification, round 1 (head `826e612b39`) **Reverse verification.** The fix was committed first. I then restored the old drive transcription `{ id: 'pkg-a', name: 'A' }` through `scripts/ablation-replace.mjs`, with the anchor counted 1 → 0, the replacement 0 → 1, and the blob `e976bff69a` → `5b952ed89a`. - **Predicted beforehand:** 2 red, 73 green. The two reds were the new 1a pin, plus the objectstack-ai#17534 lit control in "the missing keys are what decide it", which now takes the drive's `version` through the spread. - **Observed:** `Tests 2 failed | 73 passed (75)`, exactly those two. - **Restore:** the blob is back to `e976bff69a`, equal to HEAD, and `git diff HEAD` is empty. Restore was proven by hash, not by exit code. **Spec package** - **Test:** `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` gives `Test Files 527 passed (527)`, `Tests 15504 passed | 1 todo`, exit 0. - **Typecheck:** `pnpm --filter @objectstack/spec run typecheck` exits 0. `check:test-typecheck` reports OK. `tsc -p tsconfig.test.json --listFilesOnly` lists `src/api/package-api.test.ts` (1 hit), and that file has no debt-ledger entry. - **Build:** `turbo run build --filter=@objectstack/spec` exits 0. `git status` was clean afterwards, so no generated artifact moved. **Gates.** From `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at this head: 82 derived, run one by one with each exit code captured before any pipe. `--ran` reports: «✓ dispatch-gates --ran: 82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED (a DERIVED zero — all 82 recorded an exit code and none of them is 3)». - Two gates first answered exit 3 (PREREQUISITE NOT MET) on this fresh worktree: `check:dual-build-cjs-loads` and `check:type-check-debt`. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72 tasks, exit 0), both measured 0: - «✓ check:dual-build-cjs-loads — 104 published require entry point(s) across 67 package(s) load». - «check-type-check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured». **Lint (a narrowed pass, stated as one).** I ran `eslint --no-inline-config --format json` on the three touched paths. The JSON has 3 results. The two `.ts` files show 0 errors and 0 warnings. The `.md` is outside eslint's own population ("File ignored because no matching configuration was supplied"). This narrowing covers everything the full run would: `eslint.config.mjs` never enables type-aware linting (its note at lines 327-328: no `parserOptions.project`, no typed rules), so this diff cannot move the verdict of any untouched file. The full `pnpm lint` union is CI's. **History.** The first commit `b685c67ad2` also rewrote the pending `.changeset/18058-install-door-contract-rebind.md`. `check:empty-changeset`'s foreign-changeset rule refused that with exit 1. The second commit `826e612b39` restores the file byte-identical to the merge base, and the gate reads exit 0 at head. Acceptance note 1 below has the details. ## Patch round: the registry drive, transcribed as it posts (head `0251069c5`) The at-tier contract review of head `826e612b39` (record `5808378321`) failed one item. This PR had transcribed the registry drive as `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` and filed it under the open `201` residual. That drive stopped posting that body at PR objectstack-ai#19473; the body is now the drive file's REVERSED pin, answered `400`. Commit `0251069c5` corrects every copy in one round. The clause split, 1a's CLOSED marking, the count sentence and the `patch` level are unchanged. **Read at both refs before any edit.** `packages/runtime/src/domain-handler-registry.test.ts` and `packages/runtime/src/package-door-namespace-conflict-code.test.ts` are each byte-identical at the merge base `fdeeea0cc9` and at `origin/main` `2c1011b01b`: - The duplicate-id case posts `{ id: 'com.example.pkg-a', name: 'A', version: '1.0.0' }` (`:600`). It is answered `409`, then `201` on `?overwrite=true` (`:601-604`). - The REVERSED pin posts `{ id: 'pkg-a', name: 'A', version: '1.0.0' }` (`:622`) and asserts `400` (`:623`). - The namespace drive posts `{ id, name: id, namespace, version: '1.0.0' }` (`:83`) and asserts `201` on the first install. **Clause 1b re-judged against both drives.** Both still show it, so 1b stays byte-unchanged. - **Declaration**, from a one-shot `tsx` probe on `src` (not committed): both real bodies fail `ManifestSchema` on `type` alone, and both parse once `type: 'app'` is added. The stale body fails on `id` and `type`. - **Door**, at `0251069c5`: `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 --reporter=verbose src/domain-handler-registry.test.ts src/package-door-namespace-conflict-code.test.ts` gives `Tests 57 passed (57)`. That includes the duplicate-id case (`409`, then `201`), the REVERSED pin (`400`) and namespace section 1 (first install `201`). **What changed** - `package-api.zod.ts`, the drives paragraph only: - It quotes the real body, and says the declaration refuses both bodies on `type` alone while the door answers both `201`. - It credits the `version` repair to PR objectstack-ai#19326 and the id repair to PR objectstack-ai#19473. - It says the door answers the old `pkg-a` body `400`, so that body is not part of the residual. - `package-api.test.ts`: - `DOOR_DRIVE_REGISTRY` holds the real body. - The control that rested on the stale id now asserts that the real body parses once `type` is added. It keeps the old `pkg-a` reading, refused on the id alone. - The clause-1a pin's comment now names the versionless first transcription it meant. - `DOOR_201_RESIDUALS` and its doc comment are unchanged: with the corrected constant, every entry is a body the door answers `201`. - `.changeset/19327-install-door-residual-split.md`: the drives sentence names the body the drive posts, and the `pkg-a` body the door answers `400`. **Reverse verification.** Run after the fix was committed, through `node scripts/ablation-replace.mjs`, which put the stale transcription back. - Mutation: anchor ×1 → ×0, replacement ×0 → ×1, blob `37e99f04ca` → `0331f39bc4`. - Predicted beforehand: 1 red, the renamed control. - Observed: `Tests 1 failed | 74 passed (75)`. The failure is at `package-api.test.ts:945` (`expected false to be true`), the parse of the real body plus `type`. - Restored: the blob is back to `37e99f04ca`, equal to HEAD, and `git diff HEAD` is empty. - Round 1 had no assertion that could go red on this transcription. This head has one. **Verification at `0251069c5`** - **`package-api.test.ts`:** `Tests 75 passed (75)`. On `origin/main` `2c1011b01b` plus this branch's patch it gives `Tests 79 passed (79)`; the patch applies cleanly beside objectstack-ai#19937's changes to the same two files. - **Spec tests:** `--project local` gives `Test Files 527 passed (527)` and `Tests 15504 passed | 1 todo`. The three `repo`-project tests that read `.changeset/` or mention `package-api` give `136 passed`. - **Typecheck:** `pnpm --filter @objectstack/spec run typecheck` exits 0. `package-api.test.ts` is in `tsconfig.test.json`'s program and has no debt entry. - **Build:** the built `dist/api/index.d.ts` and `.d.mts` each carry the real body once and the stale body zero times. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 82. Deriving on `origin/main` plus the patch gives the same 82, with an empty set difference both ways. - `--ran` on the record as captured: «82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)». - The two were `check:dual-build-cjs-loads` and `check:type-check-debt`, with PREREQUISITE NOT MET on a fresh worktree. - After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72 tasks, exit 0), separate re-runs measured both at exit 0: «104 published require entry point(s) across 67 package(s) load» and «4 ledger entr(ies) re-measured … none above its recorded number». - **Lint, a narrowed pass stated as one:** `eslint --no-inline-config --format json` on the three paths gives 3 results. - Both `.ts` files have 0 errors and 0 warnings. The `.md` is outside eslint's population. - `eslint.config.mjs` enables no type-aware linting (its note at `:327-328`), so this diff cannot move any untouched file's verdict. - The full `pnpm lint` is CI's. ## Acceptance notes 1. **The pending release note `.changeset/18058-install-door-contract-rebind.md` restates the same clause.** - It reads «a manifest missing `type` and/or `version` (both of the runtime's own door drives post one)». That has been half false since objectstack-ai#19326. - It will publish into the `@objectstack/spec`, `@objectstack/runtime` and `@objectstack/client` CHANGELOGs at the next release. - It is not edited here. Correcting another PR's pending release note is a release decision that `check:empty-changeset` routes to a person, and that gate stays red on any PR that makes the edit. The proposed replacement sentence goes to the seat in the report. 2. **Clause 5 of the same residual list is stale too.** - It says the door answers `400` to a whitespace-only `id` «this declaration admits». - Since PR objectstack-ai#18319, `ManifestSchema.id` carries `MANIFEST_ID_PATTERN` and the declaration refuses it. `package-api.test.ts` already pins that refusal. - Not touched: the card fences the other classes. Reported to the seat. 3. **The header of `packages/runtime/src/domains/packages-install-manifest-version.test.ts` counts the docblock's five classes differently from the docblock.** - The header counts `version`, `type`, unknown keys, string-typed options and bare-form options. - The docblock has `type`/`version` as one class and the whitespace `id` as class 5. - This predates this PR and is not made false by it. That runtime file is not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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