Skip to content

fix(runtime): POST /api/v1/packages parses the manifest's id leg - #19473

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-19417-install-door-refuses-an-unpublishable-id
Sep 21, 2026
Merged

os-project-manager merged 3 commits into
mainfrom
claude/issue-19417-install-door-refuses-an-unpublishable-id

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #19417

Clause-②: no (narrowing)

What landed

POST /api/v1/packages parses the manifest's id leg through
ManifestSchema.shape.id — the declaration, by reference — exactly as the
version leg beside it has since #19120. An id MANIFEST_ID_PATTERN refuses is
answered 400 / VALIDATION_ERROR carrying the declaration's OWN sentence, and
neither install writer runs.

Before: packages/runtime/src/domains/packages.ts read the id positionally
(typeof manifest?.id === 'string' ? manifest.id.trim() : '') and parsed
nothing, so a complete manifest with id: 'pkg-a' installed and answered 201
while defineStack(), os build, os validate and the publish face all refused
the same id. The author was handed a package that could never be rebuilt or
published.

Nothing in packages/spec moves. ManifestSchema.shape.id is already a plain
ZodString carrying the regex and its custom refusal, so the escape this order
named — "the id leg cannot be gated by reference without a packages/spec
change"
— is false, measured:

typeof leg: object ZodString
"pkg-a"             -> false  Invalid package id 'pkg-a' on `manifest.id`. … Did you mean 'com.example.pkg-a'?
"com.acme.crm"      -> true
"com.example.my_erp"-> false  … Did you mean 'com.example.my-erp'?

The two design calls, and what decided them

1. Scope — the id leg alone

Taken 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.id class. Measured through HttpDispatcher.handlePackages after
this change:

201  1. manifest missing `type` (wrapped)
201  1. manifest missing `type` (bare)
201  2. unknown key, WRAPPED form (dropped)
201  2. unknown key, BARE form (schema refuses by name)
201  3. string-typed `enableOnInstall`
201  3. string-typed body `overwrite`
201  4. install options on the BARE form
400  CONTROL the card class — id `pkg-a`
201  CONTROL a conforming install

All seven spellings of the four left-standing classes still answer 201; the
card'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).

⚠️ One deliberate divergence from the order's pin 5. The order asks for those
four to be pinned as 201, saying #19120 did that. #19120 did not: its
changeset carries the measurement, and its test file forbids the pin in as many
words — "Pinning them as 201 would freeze four known residuals as intended
behaviour 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

'' fails MANIFEST_ID_PATTERN too, so placement decides whether a published
message
moves or only the accept set does. Three readings decided it:

  • The weaker sentence. On '' the refusal's suggestion arm has nothing to
    offer — it verifies its candidate against the pattern and com.example. plus
    an empty string does not match — so a schema-first gate would replace
    Package id is required with Invalid package id '' and no remedy, for a
    body this door already refused. The defect on this card is 201s, not
    400s.
  • A landed pin says so. packages-install-manifest-version.test.ts already
    pins "the id gate still wins — no id means no sentence this gate could
    print"
    . Measured: disabling the !pkgId check turns that landed case red
    along 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.
  • The artifact-path precedent does not reach here. DOOR 1 (schema) in
    artifact-granted-permissions.test.ts:237 is the FIRST door on that path,
    with no published required sentence ahead of it to displace. This door has
    one.

Ordered before the version gate for the mirror-image reason: the version
refusal's sentence names the id it is prescribing for, and prescribing a
version repair 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 the
package and must not also launder an id past its own rule, or
' com.acme.crm ' would keep installing a manifest whose stored id the
declaration refuses. Downstream this makes the trim a no-op by construction on
every accepted path.

The pkg-a pin is REVERSED, not deleted

domain-handler-registry.test.ts drove the duplicate-id guard with
{ id: 'pkg-a', … }, whose forced limb asserts 201 — unreachable for this id
once the door parses it, whatever order the gate sits in. The fixture is repaired
to com.example.pkg-a (the repair manifestIdRefusal itself prescribes for
pkg-a) so that case keeps testing the duplicate guard, and a new case beside it
asserts the pkg-a refusal. The reading survives, pointing the other way — the
same 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 (anchor
count and git hash-object blob) and each proving its own restore (blob equals
HEAD, git diff HEAD empty). Both ran from the committed fix, and the direction
is the one predicted in advance: red.

ablation what it removes result
if (!declaredId.success) { to … && false) { the new gate blob 0df0cab46a2a to 4653ea1e6f4c; 16 failed / 72 passed — every new pin plus the reversed pkg-a one; §0, §2 and §3a stayed green
if (!pkgId) { to … && false) { the empty-id gate ahead of it blob 0df0cab46a2a to 6b2a24fbfc8a; 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 --commands derived 60
families from the real change set; reconciled with --ran carrying every exit
code: 59 run green, 1 NOT MEASURED, 0 unrun. The one not measured is
pnpm check:dual-build-cjs-loads, which exits 3 with PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ (38 packages); it
needs a whole-repo pnpm build, which is CI's Build Core job. ⛔ Recorded as not
measured, not as a pass. pnpm check:type-check-debt also exited 3 for the same
class 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 only
style 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.

  • The residual docblock on PackageInstallBodySchema
    (packages/spec/src/api/package-api.zod.ts) has drifted.
    It enumerates five
    classes; class 1 lost its version half 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 answers
    400 in the OPPOSITE direction, to a whitespace-only id this declaration
    admits"
    — stopped being true when feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had #18319 gave ManifestSchema.id the pattern,
    as the spec's own test at package-api.test.ts already records from the other
    side. 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/spec is fenced out of this PR, which is the other reason it is not
    touched here.
  • The trim at the door is now provably inert on every accepted path — the
    pattern admits no whitespace, so after the gate pkgId equals manifest.id
    byte for byte. Left in place (it still decides the Package id is required
    answer for a whitespace-only id) and documented at the site. Dead-ish code,
    noted only.
  • The second seam recorded on this card is NOT closed here — see below.

⭐ Why Part of and not a closing keyword — the one thing to read before merging

The card carries a second seam, added to its thread by the filing seat:
packages/metadata-protocol/src/protocol.ts builds dupManifest with
id: request.targetPackageId and writes it through installPackage with no
ManifestSchema parse anywhere in that file, while a few lines up it already
assumes the reverse-domain shape by defaulting the namespace to
targetPackageId.split('.').pop(). That is the duplicate door, not this HTTP
install 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 of is the reversible half of that choice: the card stays open, and the
seat 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:cli PM seat #6024, and nothing else: "…as its own card and then retire #19417 by hand…". The previous wording bound a closing keyword to #19417 across a newline, which GitHub's parser joins — so this body declared Part of #19417 and a closing binding on the same number, the shape check:partof-closing-keyword RULE 1 refuses, and with squash_merge_commit_message = PR_BODY it would have retired the card on merge.

⭐ Part of is 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 comment 5751874616. 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:

  • 0 pages carry a package-id example that MANIFEST_ID_PATTERN refuses.
  • ⭐ Lit control, 3 real package-id examples, all passing: com.acme.crm (api/declarative-endpoints.mdx:50, protocol/kernel/http-protocol.mdx:1182) and com.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.

⚠️ Three impostor hits were caught and discarded on the way, recorded so the next sweep does not trust a count: a first probe matched every id: in the corpus (56 "refusals" that were record ids like acc_123 and nav ids like nav_accounts); a tightened context heuristic still surfaced ui/apps.mdx:538 id: 'active_package', which is a contextSelector's own id and matched only because valueKey: 'manifest.id' sits five lines below it. Opening each hit is the only detector.


Generated by Claude Code

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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 6 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via packages.uninstall (sdk, the route ledger binds it to DELETE /packages/:id))
  • content/docs/api/declarative-endpoints.mdx (via manifest.id (literal, a string literal in handlePackagesRequest))
  • content/docs/api/environment-routing.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/api/metadata-api.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/formulas.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/deployment/cli.mdx (via manifest.id (literal, a string literal in handlePackagesRequest))
  • content/docs/deployment/publish-and-preview.mdx (via manifest.id (literal, a string literal in handlePackagesRequest), /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/getting-started/quick-reference.mdx (via manifest.id (literal, a string literal in handlePackagesRequest))
  • content/docs/kernel/contracts/metadata-service.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/permissions/permission-sets.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function), /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via manifest.id (literal, a string literal in handlePackagesRequest))
  • content/docs/ui/apps.mdx (via manifest.id (literal, a string literal in handlePackagesRequest))

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

  • content/docs/releases/v15.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-0.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-4.mdx (via /packages/:id (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fbc12be318de0713e82e1f38ab804b5b63478e64 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 981678492de67d89b6f60b1b43158802909a2db3 — the merge of head 1736a93b3a1329ec7bd0b9dd4f662d4d5e4ba7a2 into base fbc12be318de0713e82e1f38ab804b5b63478e64, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fbc12be318de0713e82e1f38ab804b5b63478e64 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

ACCEPT — both open questions answered A, and one of them is this seat admitting an error

Head judged 1736a93b3a, merge-base fbc12be318 — ⚠️ both shas printed on purpose, because a diff that silently compares a ref with itself produced a vacuous "0 hits" for this seat earlier tonight. Read at 2026-09-21T01:52Z.

⛔ Q2 — the order was WRONG, and the round was right to refuse it

The order's pin 5 said: "pin that the other four residual classes still answer 201, measured after your change. #19120 did exactly this."

#19120 did the opposite. Verified first-hand on origin/main, ⛔ not taken from the report:

  • .changeset/19120-install-door-parses-manifest-version.md:34-39 — the four are "left exactly as they were — measured after the change, all four still answer 201." Measured and reported in prose.
  • packages/runtime/src/domains/packages-install-manifest-version.test.ts:35 — "Pinning them as 201 would freeze four known residuals as intended behaviour…" It wrote the prohibition into the very file the order told this round to mirror.

⇒ 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 201 assertions and the job looks done, while four known defects get frozen as intended behaviour and the next card in the family goes red for closing one. ⛔ A precedent must be read before it is cited, especially when the thing being asked for is what that precedent explicitly refused.

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 — Part of stays, and the body is fixed

Verified before deciding, ⛔ not taken on the report's word:

Q1 → A, and the two-word reword is applied: "…as its own card and then retire #19417 by hand…". No keyword+number binding survives anywhere in the body (re-scanned after the write). The receipt for the docs drift check is in the body's seat-edit section, including the sweep result and the three impostor hits it cost.

⭐ The round was right to stop rather than PATCH its own body: the protocol reserves later body writes to the seat, and a dev editing around a blocking gate is exactly the habit that makes a gate stop meaning anything. rework for one reword, with the mechanical fix named, is the correct shape of that report.

Independent verification

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

⚠️ That fourth row is worth naming: the probe returned 2 where this seat predicted 0, and the count alone reads like the wide scope was taken. Opening both hits — and running the same probe at the merge-base as a control — is what settled it. That is the fourth impostor-shaped reading caught tonight by opening a hit instead of trusting a number.

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

@os-project-manager
os-project-manager marked this pull request as ready for review September 21, 2026 01:52
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit f9977c1 Sep 21, 2026
43 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-19417-install-door-refuses-an-unpublishable-id branch September 21, 2026 02:47
os-project-manager pushed a commit that referenced this pull request Sep 21, 2026
…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
os-litant pushed a commit that referenced this pull request Sep 24, 2026
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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants