Skip to content

fix(runtime): POST /api/v1/packages parses the manifest version leg instead of installing anything it is handed - #19326

Merged
os-project-manager merged 5 commits into
mainfrom
claude/issue-19120-packages-install-manifest-parse
Sep 20, 2026
Merged

os-project-manager merged 5 commits into
mainfrom
claude/issue-19120-packages-install-manifest-parse

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #19120

Clause-②: no (narrowing)

POST /api/v1/packages installed a manifest with no version and answered 201, while the published declaration for that door has always required one. The door now parses that leg and refuses loudly.

What changed

PackageInstallRequestSchema binds manifest: ManifestSchema, and ManifestSchema declares version required with a semantic grammar. The door parsed nothing at all — const manifest = body.manifest || body went straight to installPackage, with an id check as the only gate on the way.

The install branch now asks the declaration by reference and refuses with 400 / VALIDATION_ERROR plus a prescription naming the key and its shape.

ManifestSchema.shape.version.safeParse(manifest?.version)

Three properties of that spelling are load-bearing:

  • By reference, not by copy. The PATCH /packages/:id door further down the same file keeps its own hand-written semver regex. A second copy here would be the third judgment of one key on one surface. Because this door asks the declaration, whatever the open version-grammar canon question settles on reaches it with no edit to this file.
  • No new error code. deps.error(msg, 400) derives VALIDATION_ERROR from standardErrorCodeForHttpStatus(400), the standard catalog's member for 400 — the same dialect the repeated-query-parameter refusal on this route already answers in. Nothing in packages/spec moves, and nothing in it was touched.
  • Ordered ahead of the duplicate-id 409. A request-shape refusal must not depend on server state. Placed after the 409, one and the same under-specified body would answer 400 or 409 according to whether that id happened to be installed already — two answers to one authoring mistake. The id gate above it still wins, because with no id there is nothing to name in the sentence this gate prints.

The scope fence held — the five classes are SEPARABLE

The card is graded on the version leg alone. The declaration's residual docblock records five classes this door answers 201 to; the dispatch asked whether enforcing version forces the other four along.

It does not. The structural reading: the five are produced at three different levels — a per-key field schema in ManifestSchema.shape (this leg), the union arms' strict close (unknown keys, bare-form options), and this handler's own === true / === 'true' comparisons (the string-typed options). The single call that would close four at once is PackageInstallBodySchema.safeParse(body), and that call is pointedly not made. ManifestSchema.shape.version is an independent field schema that parses one value with no reference to any other key or to the envelope.

Measured at this head, after the fix, driving the real door (one-shot probe, not a committed test):

class case status installed
1a version wrapped, no version 400 VALIDATION_ERROR no
1a version bare, no version 400 VALIDATION_ERROR no
1a version off-spec grammar 400 VALIDATION_ERROR no
1b type wrapped, no type 201 yes
1b type bare, no type 201 yes
2 unknown keys unknown key, wrapped form 201 yes
2 unknown keys unknown key, inside the manifest 201 yes
2 unknown keys unknown key, bare form 201 yes
3 string-typed enableOnInstall: 'false' 201 (enabled) yes
3 string-typed overwrite: 'true' on the body 201 yes
4 bare-form options settings on the bare form 201 yes
5 whitespace id id of spaces only 400 (pre-existing) no

Only the version rows moved. The other four classes are untouched in both directions, and no committed test asserts anything about them — 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.

Note for whoever takes the residual next: the docblock's clause 1 reads "a manifest missing type and/or version", and after this PR only the type half of it is still true. That text lives in packages/spec, which is the domain:spec seat's — flagged in the report, not edited here.

Tests

packages/runtime/src/domains/packages-install-manifest-version.test.ts, 20 cases:

  • §0 asserts each fixture off-spec by the declaration before §1 holds the door to it. If the version-grammar canon moves, §0 goes red first and names what happened, instead of §1 failing for a reason a reader has to reconstruct.
  • §1 pins both halves a refusal owes: 400 + VALIDATION_ERROR + success: false on the wire, and that neither install writer was called. A door that answered 400 and installed anyway satisfies a status assertion alone.
  • §2 is the positive control, on both body forms and both install limbs, asserting the manifest reaches installPackage unchanged — this is a gate, not a normaliser.
  • §3 pins the ordering, with the 409 itself as the control that the 409 was not simply removed.

One fixture repaired outside the new file. packages/runtime/src/domain-handler-registry.test.ts's duplicate-id case drove the door with { id: 'pkg-a', name: 'A' } — a body ManifestSchema has never admitted. Its subject is the 409 guard and the ?overwrite=true bypass, not manifest completeness, so it gets the version it never carried. The repair is owed whatever order the new gate sits in: that case's ?overwrite=true limb asserts 201, which an under-specified manifest must never reach, so no placement of the gate leaves the fixture valid.

Verification

Final head 0ccf7296d4, after git merge origin/main.

  • pnpm --filter @objectstack/runtime test — 269 files / 3725 passed, 1 skipped, exit 0
  • pnpm --filter @objectstack/runtime typecheck — exit 0
  • pnpm --filter '@objectstack/runtime...' build — exit 0
  • 58 of 58 derived gate families run, 0 NOT-MEASURED, 0 UNRUN (dispatch-gates.mjs --ran, exit codes captured before any pipe). check:dual-build-cjs-loads answered PREREQUISITE NOT MET on the pre-merge run and measures green here once its build closure existed.
  • pnpm lint — the full unnarrowed repo union (eslint . --no-inline-config), exit 0, at this head. Not a narrowed run, so no narrowing is claimed.
  • check-adr-0087-registration reads the changeset as [BREAKING+clause-②-narrowing] carrying not-required (no-migration-prescription).

Changeset

minor, declared breaking. Not major: the launch-window guard ships breaking changes as minor while the stack versions in lockstep. The ADR-0087 disposition is not-required (no-migration-prescription) — nothing authorable is removed, renamed or reshaped, so objectstack migrate meta has nothing to reach; there is no old spelling that maps to a new one, only a key the declaration already required.

Acceptance notes

  • The residual docblock is now half-stale and needs the domain:spec seat: clause 1 of PackageInstallBodySchema's docblock records type and/or version as one 201 class, and only type remains. Reported, not edited — packages/spec is not this card's to touch.
  • The four other residual classes stay open and each needs its own reading, per triage. Measured unchanged above.
  • The PATCH /packages/:id door keeps a hand-copied semver regex. Correct today and not a defect, so not filed — but it is the copy that has to be found by hand if the version-grammar canon moves, while this door follows the declaration on its own. Noted for whoever takes that canon card.

Generated by Claude Code

`PackageInstallRequestSchema` binds `manifest: ManifestSchema`, which
declares `version` required with a semantic grammar. The install door
parsed nothing at all — `const manifest = body.manifest || body` went
straight to `installPackage` with only an id check on the way — so a
manifest with no `version` installed and answered `201`.

The door now asks the declaration by reference
(`ManifestSchema.shape.version`) and refuses loudly with a prescription,
`400` / `VALIDATION_ERROR` through the surface's own `deps.error`.

Scope is the `version` leg alone. The four other residual classes the
declaration's docblock records are separable and are left standing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…l, order

§0 asserts each fixture off-spec BY THE DECLARATION before §1 holds the
door to it, so a moving version-grammar canon reds the premise and names
itself rather than failing an opaque case downstream.

§1 pins both halves a refusal owes: 400 / VALIDATION_ERROR on the wire,
and neither install writer called. §2 is the positive control on both
body forms and both install limbs. §3 pins the gate ahead of the
duplicate-id 409, with the 409 itself as the control.

⛔ No case asserts anything about the four residual classes this card
does not close — in either direction.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
…arried

`ManifestSchema` has always declared `version` required, so
`{ id: 'pkg-a', name: 'A' }` was never a legal input to the door this
case drives — the fixture's own defect is what the case reports now that
the install door parses that leg.

The repair is owed whatever order the new gate sits in: the `?overwrite=true`
limb asserts `201`, which an under-specified manifest must never reach.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
`minor`, not `major`: the launch-window guard ships breaking changes as
minor while the stack versions in lockstep.

Declared breaking and `Clause-②: no (narrowing)` — the door's published
accept set shrinks, back toward what the declaration always said.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

9 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/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/publish-and-preview.mdx (via /packages/:id (route, a path literal in a comment on a changed line))
  • 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))

⛔ 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 84a7e9e62f874b8e931a3f2fa2961505a351dc4a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4a5b17eae29c22f9da63dc33a8a8ab5afe2249ea — the merge of head 0ccf7296d4ff13d2513214dc12fd28f1807c5581 into base 84a7e9e62f874b8e931a3f2fa2961505a351dc4a, 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 4a5b17eae29c22f9da63dc33a8a8ab5afe2249ea && git checkout 4a5b17eae29c22f9da63dc33a8a8ab5afe2249ea
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84a7e9e62f874b8e931a3f2fa2961505a351dc4a 0ccf7296d4ff13d2513214dc12fd28f1807c5581 && git checkout -B drift-repro 84a7e9e62f874b8e931a3f2fa2961505a351dc4a && git merge --no-ff 0ccf7296d4ff13d2513214dc12fd28f1807c5581

node scripts/docs-audit/affected-docs.mjs --json 84a7e9e62f874b8e931a3f2fa2961505a351dc4a

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

@os-project-manager
os-project-manager marked this pull request as ready for review September 20, 2026 11:57
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 13d5294 Sep 20, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-19120-packages-install-manifest-parse branch September 20, 2026 12:36
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
…cute, and retire the two they never did (objectstack-ai#19364)

Part of objectstack-ai#17667

Clause-②: yes

Ruling of record: comment `5651023067` — director seat, decision batch
objectstack-ai#126 item 1, maintainer 「同意」 (live PM chat 2026-09-13) to
`1(2)·2A·3A·4B`. **Route 2**: the door's declaration and its reads are
aligned. ⛔ Not re-adjudicated here.

`Part of`, not `Fixes`, and the reason is measured rather than cautious
— see **Why this does not close the card** below. The dispatch asked for
`Fixes objectstack-ai#17667`; that instruction is overridden by the standing rule that
a merge which should not close a card uses `Part of` and names the half
it leaves. Flagged rather than silently chosen.

## STEP ZERO first — the ruling's own precondition did NOT stop the work

Ruling item 4 makes the dispatch's first act a stop condition: if a
platform-wide cursor convention already exists and `/packages` is the
only holdout, route 1 **by reuse** is re-priced and the taker stops.
Measured in this worktree at `81e12e1`, 2026-09-20T12:05Z:

- **No shared pagination helper reaches any REST list door.** The only
cursor codec in the tree is `encodeStorageListCursor` /
`decodeStorageListCursor`
(`packages/spec/src/contracts/storage-service.ts`) — the
storage-**adapter** `list()` contract. Imports of it outside
`service-storage` and its own contract file: **zero**. Imports of any
`Cursor`-named symbol by `packages/runtime/src/**` or
`packages/rest/src/**`: **zero**.
- **Lit controls on the same greps, so those zeros are readable.**
`parseIntegerParam` (`packages/runtime/src/query-param.ts`) IS found
shared across two dispatcher domains, and `refuseUnknownQueryParams` IS
found shared across two `packages/rest` files. The search finds shared
helpers when they exist.
- **`/packages` is not the only holdout — it is one of four.**
`ListExportJobsRequestSchema`, `ListAiConversationsRequestSchema` and
`ListRunsRequestSchema` all declare `limit` and/or `cursor`; none
paginates. `GET /automation/:name/runs` even validates `cursor` at the
boundary and then returns `{ runs, hasMore: false }`, with its own
comment recording that "today's engine ignores the option entirely" —
the same shape as this door, one domain over.
- **The platform's travel is the other way.**
`api/ListNotificationsRequest:cursor` (objectstack-ai#6361) and `data.query.cursor`
(objectstack-ai#4286) were both retired before this one.

⇒ the stop condition is false in both of its conjuncts. Proceeding to
items 1 and 3 was measured, not assumed.

## Ruling item 1 — declare what the doors already execute

| door | parameter | read site | now declared on |
|---|---|---|---|
| `GET /api/v1/packages` | `type` | list branch, `manifest.type`
equality | `ListInstalledPackagesRequestSchema` |
| `GET /api/v1/packages/:id` | `version` |
`readRequestedVersion(query?.version)` |
`GetInstalledPackageRequestSchema` |
| `DELETE /api/v1/packages/:id` | `keepData` | uninstall branch |
`UninstallPackageApiRequestSchema` |
| `POST /api/v1/packages` | `overwrite` | install branch | **already
declared — see below** |

No accept set moves: the doors served all four before and serve them
identically now.

Each declaration is measured from the handler's actual read, not from
the card:

- **`type`** is an open `z.string()`, deliberately not an enum. The door
compares `manifest.type === query.type` on any non-empty value, and
`ManifestSchema.type` is no shared vocabulary — a narrower declaration
would state a rejection this wire does not perform. An unmatched value
is not an error; it selects nothing.
- **`version`** is a plain string. `latest` means "the installed row"
and is equivalent to omitting the key; comparison is exact string
equality against `manifest.version`, with no semver-range semantics, and
the id is resolved first so an unknown id keeps its existing 404
wording. All of that is in the key's docblock so the next reader does
not have to open the runtime.
- **`keepData`** is declared boolean, and the docblock records the two
spellings the wire actually honours — `keepData=true` and `keepData=1` —
and warns that anything else, `keepData=yes` included, reads as absent
and DROPS the tables. Widening the door's own comparison would be a
runtime change this declaration is not.

### ⚠️ Premise drift: `overwrite` was already discharged, by the PR that
unblocked this card

The card's body (2026-09-11) lists `?overwrite=` as read-and-undeclared.
That is no longer true. PR objectstack-ai#19130 merged 2026-09-20T11:11:16Z — the same
landing this card had been serialised behind — and it declares
`overwrite: z.boolean().optional()` on `PackageInstallRequestSchema`,
with a docblock that already names the `?overwrite=true` query spelling.
One quarter of ruling item 1 needed nothing. **No edit was made for
it**, deliberately: re-declaring it would have been churn, and the
existing declaration is better than one written from the card.

## Ruling item 3 — retire `limit` and `cursor`, `.default(50)` included

Both keys are `retiredKey()` tombstones, not deletions. The schema is
not `.strict()`, so a bare deletion makes Zod silently strip whatever a
generated client keeps sending — a clean parse and a parameter that
never takes effect, which is this card's own defect moved one layer down
(ADR-0104). Writing either key is now a `tsc` error and a parse error
carrying the prescription.

The prescription names the removed default specifically, because that is
the load-bearing half: a reader of the published schema was entitled to
believe an unparameterised list is capped at 50 rows, and it has never
been capped at all.

**The retirement kit, and the two entries it deliberately does NOT
have.** Precedent hunted and followed:
`api/ListNotificationsRequest:cursor` (objectstack-ai#6361) is the same shape one
route over — an HTTP-only request key retired with a tombstone and a D3
semantic entry. Zone 2 flagged this precedent as unverified by the seat;
it exists, and this change copies it.

- `RETIRED_KEYS_BY_MAJOR[18]` — two entries, one file each, generated
into `migrations/registry.ts` by `gen:migration-registry`.
- D3 semantic entry `packages-list-pagination-retired`, carrying
`surface` / `replacement` / `reason` / `acceptanceCriteria` to
`spec-changes.json`, the generated upgrade guide and `os migrate meta`.
- Registered at **18, not 17**, per the `ui/ListView:pageName` and
`security/ObjectPermission:allowPurge` convention: the removal ships on
the 17.x line as a minor, and the prescription lives at the major
boundary where `migrate meta` users look. The guidance string says
`17.5.0`, the shipping version, matching `view.pageName`.
- **No D2 conversion**, and the asymmetry is the point: a conversion
rewrites an authored source or a stored `sys_metadata` row, and this
shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and
nothing persists one. The `os migrate meta` house sentence is therefore
correctly absent from the prescription; the pin only judges
prescriptions that name the command.
- **No `acceptRetiredDefaultResidue` stage**, for the same reason one
layer along. That helper exists for a retired default the published
toolchain materialized into built artifacts. Nothing has ever parsed
this schema, so the `.default(50)` reached no artifact and there is no
residue population. The `authorable-defaults/api.json` line simply
leaves with the key — `DEFAULT_CHANGES_BY_MAJOR` excludes retirements by
name, and `check:authorable-surface` accepted it without one.
- **No liveness-ledger row** to touch: `liveness/api.json` is the `api`
METADATA type's ledger, not the spec `api/` category. Zero occurrences
of `ListInstalledPackages` in it.

**Ratchet readings, stated because their direction is route-dependent:**
`authorable-surface/api.json` gains two `[RETIRED]` rows and three new
keys; `authorable-defaults/api.json` loses exactly the `= 50` line;
`api-surface/` is unchanged, which is correct for a key-level narrowing
on a surviving def.

### `hasMore` is now true by construction, and the comment says so

`hasMore` stays the constant `false` it already was. With no `limit` and
no `cursor` to ask with, nothing can request a page, so there is never a
next one to announce. That is recorded at the response declaration — the
return site itself is in `packages/runtime/src/domains/packages.ts`,
which this dispatch is fenced off — with an explicit warning against
"fixing" the constant back into a computed value before a request-side
way to ask exists. Pinned by a test.

## Why this does not close the card

Ruling item 2 — `enabled` implemented in
`packages/runtime/src/domains/packages.ts`, one filter line in the shape
`status` already has — is assigned by the ruling to the **cli seat's
sibling PR** and is fenced off this dispatch. Measured on the merged
`origin/main` at `2277d1f`, 2026-09-20T13:30Z: `query?.enabled` occurs
**0** times in that file; control on the same file, `query?.status`
occurs **1** time. So `enabled` is still declared-and-unread after this
PR, which is one live instance of the very class this card names.

That is recorded in the schema docblock rather than glossed, and it is
why the closing line is `Part of`. PR objectstack-ai#19326, which held that file
during dispatch, turns out to be the manifest-`version` change for
objectstack-ai#19120 and has merged; it is not the `enabled` sibling.

## Verification

Readings taken in this worktree; the gate union below was run after the
final commit, at `80937f5`.

- **Gate family, derived from the real changed paths**
(`scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived after the merge): **107 derived
· 104 run green · 3 NOT MEASURED · 0 UNRUN · 0 red**, reconciled with
`--ran` carrying each exit code captured before any pipe. The three NOT
MEASURED are the gates' own `exit 3` PREREQUISITE NOT MET:
`check:dual-build-cjs-loads` (wants a whole-repo build),
`check:type-check-debt` (a re-measure, which exits 3 by design and is a
maintainer's act to act on), and `check-plugin-teardown-shape
--self-test` (wants an unshallow checkout). None is a finding.
- `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts
up to date**, at the final head.
- `pnpm --filter @objectstack/spec test` — **501 files / 14662 tests
passed**. `pnpm --filter @objectstack/spec typecheck` — clean.
- `check:test-typecheck` GRADUATED `src/api/package-api.test.ts`: its
two recorded `TS6133` unused-import errors are gone because the new
tests use both symbols, so the shrink-only ledger entry is deleted in
this PR, as that ratchet requires.
- **Reverse verification (one-off, not left in the tree).** The `limit`
tombstone was replaced on disk with its old
`z.number().int().min(1).max(100).default(50)` via
`scripts/ablation-replace.mjs`, which proved the write landed (anchor 1
→ 0, blob `de8722127dc3` → `10542945489d`) before running anything.
Direction observed: **red**, 2 failed / 68 passed — both the
prescription pin and the absence pin fire. Restore verified by blob
identity against `HEAD` and an empty `git diff HEAD`, by the tool, not
by an exit code.
- **Absence sweep, tree-scoped, with lit controls.** Authoring sites for
`limit` / `cursor` on this request shape outside the new registry
entries: **zero**; `packages.list(` calls passing either: **zero**.
Controls: `overwrite` IS found in the same spec file (10 hits) and
`packages.list(` IS found across five files by the same pattern shape.
The first-party SDK already declares `list(filters?: { status, type,
enabled })` — no `limit`, no `cursor` — so unlike objectstack-ai#6361 there is no
shipped producer to delete alongside the key.

## Acceptance notes

Out of scope, noted and deliberately not filed:

- **`gen:api-surface-declarations` output was not stable across builds
of identical sources**, and it cost this run a wrong turn worth
recording. Build objectstack-ai#1 of the unchanged `ui` sources emitted one
enum-member ordering, build objectstack-ai#2 emitted another; 184 lines of
`api-surface-declarations/ui.txt` flipped between them, and a single
control build at BASE reproduced BASE — which made one sample look like
proof that my diff caused the churn. It did not. The correct reading
needed three builds. **This finding has no surviving consumer**:
`origin/main` at `2277d1f` reverted the whole declaration-text snapshot
(objectstack-ai#19024) and deleted `api-surface-declarations/` along with its gate,
which is also the merge conflict this branch resolved by accepting the
deletion. Successor: none. Recorded here rather than filed because the
artefact and the gate that read it no longer exist.
- **Three sibling list doors carry the same declared-not-honoured
pagination shape** — `ListExportJobsRequestSchema` (`limit` with
`.default(20)`, `cursor`), `ListAiConversationsRequestSchema` (`limit`,
`cursor`) and `ListRunsRequestSchema` (`limit`, `cursor`, the last
validated at the boundary and then ignored by the engine, with `hasMore:
false` hard-coded). This is a reproducible contract divergence of
exactly this card's class, on doors this card does not name, and the
handback report carries it with dedupe words for the seat to file. ⛔ Not
filed from here and ⛔ not widened onto this PR.
- The `/packages` dispatcher domain declares no closed query-parameter
set, so an unrecognised name is still dropped rather than refused. That
is route 3, which the ruling considered and refused; noted so a later
reader does not read this PR as having taken it. Successor: whoever
converts the dispatcher domains per the incremental ingress lane.

Landing waits for the seat: this PR is a contract-review carrier and the
seat handles both the carrier and the at-tier review. Nothing here flips
ready, enqueues, arms auto-merge, requests review, or writes a label or
assignee.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ry parameter (objectstack-ai#19405)

Fixes objectstack-ai#19394

Clause-②: no (narrowing)

Ruling item 2 of objectstack-ai#17667 — comment `5651023067`, director seat, decision
batch objectstack-ai#126 item 1, maintainer 「同意」 to `1(2)·2A·3A·4B`. Items 1, 3 and 4
landed in PR objectstack-ai#19364; this is the sibling the ruling assigned to 「the cli
seat's sibling PR」. ⛔ Not re-adjudicated here. objectstack-ai#17667 stays open — see
*Card relationships* below.

## ⚠️ Two places where this PR does not match the dispatch order,
flagged rather than silently matched

**1. The changeset is `minor` with a BREAKING banner, not `patch`.** The
order said "if required, `patch` — ⛔ never `major`", and derived from
the repo's own gates the answer is neither. The diff narrows a published
wire accept set: `?enabled=1` used to answer `200` with every installed
package and now answers `400`, and `?enabled=true` used to answer every
row and now answers a subset. `scripts/check-changeset-no-major.mjs`
records the launch-window convention — "During the launch window we ship
breaking changes as `minor`" — and the sibling round in this very file,
PR objectstack-ai#19326, spelled exactly this class as `minor` + `**BREAKING**` +
`Clause-②: no (narrowing)` + an ADR-0087 marker. This PR copies that. ⛔
No `major` anywhere.

**2. The `Clause-②` line carries the `(narrowing)` arm the claim comment
did not.** The claim declared `Clause-②: no`; the value is unchanged and
still `no` (nothing here adds a schema key, a closed-set member, a
published export or a registry row — `check-widening-tells` reads 0
tells). The arm is added because the diff IS breaking in the direction
`check-adr-0087-registration` judges, and that gate's own self-test
names 「the `narrowing` ARM with no banner, no `major` and no `!`」 as a
breaking declaration. Declaring the direction is what makes the marker
owed rather than optional.

## ⭐ Which declaration this was read from, quoted

`packages/spec/src/api/package-api.zod.ts`,
`ListInstalledPackagesRequestSchema`:

```ts
  /** Filter by enabled state */
  enabled: z.boolean().optional()
    .describe('Filter by enabled state'),
```

and the same file's docblock on that schema, which names the gap this
card closes:

> ⚠️ ONE key is not there yet, and it is recorded rather than glossed:
**`enabled` is still declared here and still unread by that door.** The
same ruling closes it (item 2 — one filter line, the shape `status`
already has) in the runtime, which is a different file and a different
PR

Four things are read off that one line, and every one of them is a
decision this door would otherwise have had to invent:

- **name** — `enabled`, not `disabled` and not `status`.
- **type** — `z.boolean()`: two spellings on the wire and no third.
- **default when absent** — there is NONE. `.optional()` carries no
`.default()`.
- **absent vs an explicit value** — they DIFFER, and in the direction
that is easy to get backwards. Absent means NO filter: every row,
enabled and disabled alike, which is what this door served before.
`enabled=false` is a filter and selects the disabled rows only. That
distinction is not inferred — the first-party SDK already spells it, `if
(filters?.enabled !== undefined) params.set('enabled',
String(filters.enabled))` in `packages/client/src/index.ts`, so
`?enabled=false` arrives here from a shipped producer.

## What changed

`packages/runtime/src/domains/packages.ts`, the `parts.length === 0 && m
=== 'GET'` branch:

- the filter is read before the registry, so a request-shape refusal
does not depend on server state (the ordering argument the `version`
gate one branch over already records), and after the capability gate, so
a 400-vs-403 difference tells an unauthorized caller nothing;
- `undefined` stays reachable and never collapses into `false`;
- the row side is the row's own `enabled`, ⛔ not `status`. They are
independent keys on `InstalledPackageSchema` and a row carrying no
`enabled` counts as enabled — that schema's declared `.default(true)`.
Spelled as a two-way partition so `?enabled=true` and `?enabled=false`
sum to the unfiltered list; `p.enabled === want` would drop a key-less
row out of both halves, silently, on a 200.

### The grammar is asked, ⛔ never re-spelled

The coercion is `parseBooleanParam`
(`packages/runtime/src/query-param.ts`), the repo's one parser for a
query parameter a schema declares `z.boolean()`. `GET
/api/v1/notifications` reads its `read` key with it, and that key is
declared character for character the same — `read:
z.boolean().optional()`. So `enabled` inherits the rule that module's
header states: 「A wire contract that declares `z.boolean()` has exactly
two spellings; a third is refused rather than guessed」. A local `===
'true'` here would have been a second dialect for one declared type,
which is the `?read=1` defect (objectstack-ai#6928) that module exists to stop anyone
writing again.

Multiplicity is answered first, with the sentence this door already uses
for a repeated `?version=` (`repeatedQueryParamMessage`, objectstack-ai#17672) rather
than a second one for the identical condition. A one-element array is
one occurrence encoded differently by an adapter and is unwrapped; an
empty array is no occurrence — both per that rule's own header, which is
why the raw value cannot simply be handed to `parseBooleanParam` (it
refuses every array).

### ⛔ Why this is not literally 「one filter line, same shape as
`status`」

The ruling's wording for this item is quoted verbatim above and its
*shape* half is the one thing here that does not transfer, so it is
flagged rather than matched. `status` is declared `z.enum([…])` and read
as `if (query?.status)` plus a string comparison. Applied to a declared
BOOLEAN that guard and that comparison are wrong twice: the string
`'false'` is truthy so the guard admits it, and `p.enabled === 'false'`
matches no row at all — so the caller who asked for the disabled half
would be handed an empty list with a `200`, which is this card's own
defect pointing the other way. The filter is one line; its READ cannot
be a copy of `status`'s. Nothing in `packages/spec` moves for this and
no spec change was needed.

## Verification

Readings taken in this worktree, against `origin/main` at `c5d3d1d8b6`.

- **Gate family, derived from the real changed paths** (`node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived after the final commit): **59
derived · 58 run green · 1 NOT MEASURED · 0 UNRUN · 0 red**, reconciled
with `--ran` carrying each exit code captured before any pipe. The one
NOT MEASURED is the gate's own `exit 3` PREREQUISITE NOT MET:
`check:dual-build-cjs-loads` wants a whole-repo build ("Run `pnpm build`
first. ⛔ This is NOT a pass: nothing was measured."). It is not a
finding.
- `pnpm --filter @objectstack/runtime typecheck` — clean, `tsc --noEmit`
plus `check:test-typecheck` OK (the new test file adds no ledger debt).
- `pnpm --filter @objectstack/runtime test` — **271 files / 3761 passed
/ 1 skipped**, and `test:repo` **2 files / 69 passed**. The new file
alone is 27 tests. Repo-wide `eslint . --no-inline-config` also ran in
full at this head: **6946 files, 0 errors, 0 warnings** — a
whole-population reading, not a narrowing.
- **Reverse verification (one-off, not left in the tree).** Both halves
of the read were removed on disk with `scripts/ablation-replace.mjs`,
which proved each write landed before anything ran (anchor 1 → 0; blob
`6db66d8936e0` → `21e49bbcb79b` → `207c948d69ce`), restoring the
pre-card door: the parameter parsed by nothing and filtered by nothing.
Direction observed: **red**, **17 failed / 10 passed**. The 10 that
stayed green are the three declaration pins, the three absent-case pins
— correct, since an ignoring door still serves the absent case right —
and four preservation pins. Restore verified by blob identity against
`HEAD` and an empty `git diff HEAD`, by the harness, ⛔ not by an exit
code.
- The subject resolves through a RELATIVE import inside its own package,
so no `dist` rides between the mutation and the verdict and no rebuild
is owed on either leg.

## Card relationships

- **`Fixes objectstack-ai#19394`** — this card only.
- **objectstack-ai#17667 is not closed here.** It stays open until this lands and
until its own `nextCursor` residue rides the release retirement batch;
the card says so in as many words.
- **objectstack-ai#19328 is not addressed here.** Those four residual install-door
classes live in the `POST` branch of the same file. The diff does not
touch that branch and the new test file asserts nothing about them in
either direction — pinning them as `201` would freeze known residuals as
intended behaviour, and pinning them as refused would widen a graded
scope.

## Acceptance notes

Out of scope, noted and deliberately not filed:

- **`ListInstalledPackagesRequestSchema`'s docblock is now stale in one
paragraph** — the ⚠️ block quoted above says `enabled` is "still
declared here and still unread by that door", which stops being true
when this merges. It is prose in `packages/spec/**`, a lane this
dispatch is fenced out of, and the successor is named rather than
hypothetical: objectstack-ai#17667 is still open and its closing PR belongs to the
spec seat. Not filed as its own card, because a card whose whole content
is "delete a paragraph the open parent card already owns" is board
noise.
- **`parseBooleanParam` refuses a ONE-element array while the repo's
multiplicity rule accepts it as one occurrence.** That is why this door
composes the two rules instead of handing the raw value over. `GET
/api/v1/notifications` calls the parser directly and therefore refuses
`?read=['true']`. Not filed: it is a difference between two house rules,
not a reproducible defect against a declared contract, and no
measurement was taken against that door. Successor: whoever converts the
dispatcher domains per the incremental ingress lane.
- **The `/packages` dispatcher domain still declares no closed
query-parameter set**, so an unrecognised NAME is dropped rather than
refused. That is route 3, which the ruling considered and refused;
objectstack-ai#19364 recorded the same note and it is unchanged by this PR. Successor:
the same ingress lane.

Landing waits for the seat. Nothing here flips ready, enqueues, arms
auto-merge, requests review, or writes an assignee.

---
_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/m tests tooling

Projects

None yet

2 participants