Skip to content

Commit c02fa12

Browse files
fix(runtime): the package install door parses its whole body through PackageInstallBodySchema (#20218)
Refs #19328. This PR closes rows 1b, 3 and 4. It closes row 2 in two of its three positions. **The row left open is row 2's third position**: an unknown key at the TOP LEVEL of the wrapped form (`{ manifest, bogus }`). It still answers `201`, with `bogus` dropped, because the declaration puts the wrapped branch in strip mode and its docblock forbids closing it. That makes it a spec seam, not a door defect (see *Open question 1*). Per triage `5780789216`, this round lands as `Refs`. Clause-②: no (narrowing) ## What changed `POST /api/v1/packages` (`packages/runtime/src/domains/packages.ts`, `handlePackagesRequest`, the `parts.length === 0 && m === 'POST'` branch) now parses the whole body once, at the top of the install path. It uses the union `@objectstack/spec` already declares for this door, `PackageInstallBodySchema.safeParse(body)`. The door's own prose had named this call twice as the one call that closes the residual classes. - **What the door now enforces: the WHOLE declaration, not only the card's four rows.** Before this change, the door parsed only the `id` and `version` legs of the manifest. Each of the following therefore answered `201` and now answers `400`: `name` (the manifest's other required key); the `namespace` grammar; the closed value sets of `scope`, `runtime` and `packaging`; a declared key's type; the retired-key tombstones (`configuration`, `capabilities`, `extensions`, `loading`); and unknown keys inside the nested blocks the declaration closes. Those blocks are `contributes` and its `kinds[]`, `data[]` seeds (`SeedSchema`), `navigationContributions[]` and their items, `engine`, `engines`, and the structured form of `permissions`. The list was measured by walking `ManifestSchema` at this head. §8 of the body-contract file pins nine of these at the door, and the changeset's BREAKING paragraph names all of them, with a FROM → TO bullet for `name`. - **Verdict placement.** The verdict is answered after the `id` and `version` legs, which keep their published sentences. It comes before the duplicate-id `409`, so a request-shape refusal never depends on server state. - **Envelope.** The envelope is the one the `id` and `version` legs already use, `deps.error(msg, 400)`: status `400`, code `VALIDATION_ERROR`, taken from the standard catalog's 400 member. **No error code was minted.** - **Reads go through the parsed value.** `overwrite`, `settings` and `enableOnInstall` are read off the parsed wrapped request, never the raw body. That makes the string-vs-boolean inversion in row 3 structurally impossible rather than patched. - **Telling the forms apart.** The two forms are distinguished by `'manifest' in parsed.data`. This is the declaration's own disjointness: `ManifestSchema` is a strict close with no `manifest` key, and the wrapped branch requires one. - **The manifest is stored as sent.** The door hands the install writer the manifest the caller sent, not the parsed copy. Parsing through `ManifestSchema` adds defaults (`scope: 'project'`, `defaultDatasource: 'default'`). The existing pins (`packages-install-manifest-version.test.ts` §2) and `withWritableVerdict`'s header both record that this door stores a key-by-key copy with no defaults. The parse is a gate here, not a normaliser. - **The refusal sentence.** `installBodyRefusal` re-parses the branch of the form the caller wrote, to locate the failure. The verdict is still the union's. It surfaces each issue's own message verbatim, as the `id` leg surfaces `manifestIdRefusal`. When install options are spelled on the bare form, it appends one prescription, the declaration's own remedy: send the wrapped form, or `?overwrite=true` for `overwrite`. The option key set is read off `PackageInstallRequestSchema.shape` and is never listed by hand. The shared union-branch ranking (`zodIssuesToFields`) is not used for this. Measured, it picks the bare branch for `{ manifest: null, … }` and reports both branches for a bare body with no `type`. - **Untouched regions.** The `@objectstack/spec/kernel` import line and the `PATCH /packages/:id` version check are not touched (the PR #19637 fence). The new import is on its own line. ## Before and after, measured against the real door Method: the real `HttpDispatcher` over a real `SchemaRegistry`, with `OS_HOME` redirected, driven by a one-shot probe that is not committed. "Before" is `origin/main` `1c8b320a8`. "After" is this branch at `df0108189`. | row | request | before: status · installed · enabled | after: status · installed · enabled | |--:|:--|:--|:--| | 1b | wrapped, manifest with no `type` | 201 · yes · true | **400 VALIDATION_ERROR** · no · – | | 1b | bare, no `type` | 201 · yes · true | **400 VALIDATION_ERROR** · no · – | | 2 | wrapped, unknown key at the TOP LEVEL | 201 · yes · true | 201 · yes · true (**open: declared strip, the seam**) | | 2 | wrapped, unknown key INSIDE the manifest | 201 · yes · true, key **stored** | **400 VALIDATION_ERROR** · no · – | | 2 | bare, unknown key | 201 · yes · true, key **stored** | **400 VALIDATION_ERROR** · no · – | | 3 | wrapped, `enableOnInstall: 'false'`, fresh id | 201 · yes · **true (inverted)** | **400 VALIDATION_ERROR** · no · – | | 3 | wrapped, `enableOnInstall: 'true'`, fresh id | 201 · yes · true | **400 VALIDATION_ERROR** · no · – | | 3 | wrapped, `overwrite: 'true'`, id already installed | 409 RESOURCE_CONFLICT (read as absent) | **400 VALIDATION_ERROR** · existing row untouched | | 4 | bare, `enableOnInstall: false`, fresh id | 201 · yes · true (ignored, stored as a manifest key) | **400 VALIDATION_ERROR** · no · – | | 4 | bare, `overwrite: true`, id already installed | 201 · **honoured**, stored as a manifest key | **400 VALIDATION_ERROR** · existing row untouched | | 4 | bare, `settings: {a:1}` | 201 · **honoured** as install settings, stored as a manifest key | **400 VALIDATION_ERROR** · no · – | | ctl | wrapped, well-formed | 201 · yes · true | 201 · yes · true | | ctl | bare, well-formed | 201 · yes · true | 201 · yes · true | | ctl | SDK wire shape, `enableOnInstall: false` | 201 · yes · false | 201 · yes · false | | ctl | SDK wire shape, `overwrite: true`, id already installed | 201 | 201 | | ctl | bare, well-formed, `?overwrite=true`, id already installed | 201 | 201 | **Row 4 had moved from what the card says.** The card says install options on the bare form are "ignored". Measured, they were handled key by key: `enableOnInstall` was ignored, while `overwrite: true` and `settings` were honoured. All three were stored inside the manifest. The door read `body?.overwrite` and `body.settings` without checking the form. Rows 1b, 2 and 3 read as the card relayed them. **Row 4's answer, decided by the declared schema.** `ManifestSchema`'s strict close refuses `settings`, `enableOnInstall` and `overwrite` on a bare body by name. The docblock states the remedy: «a caller that needs an option sends the wrapped form». So the declared answer is a refusal, and the refusal names the wrapped form. Neither branch is relaxed. A bare body keeps `?overwrite=true`, which the docblock names as its one route. ## The SDK control (its request shape is the one production caller of `enableOnInstall`) - **Permanent pin, door side.** `packages-install-body-contract.test.ts` §7 builds the body exactly the way `client.packages.install(m, options)` builds it, and round-trips it through JSON. It checks three calls. `install(m, { enableOnInstall: false })` answers 201 and is disabled in all three records: the returned row, the registry, and the durable file. `install(m)` answers 201 and is enabled. `install(m, { overwrite: true })` over an installed id answers 201. The SDK side of the same shape is already pinned in `packages/client/src/client.test.ts`. - **One-shot probe, not committed.** The real `ObjectStackClient` ran through the real `HttpDispatcher` from the rebuilt `@objectstack/runtime` dist, built from `0e3d3fdf5`. Results: - `install(m, { enableOnInstall: false })` → `enabled: false`, and the registry row is `false` too. - `install(m)` → enabled. - `install(m, { overwrite: true, enableOnInstall: true })` → 201, and the manifest was replaced. - `install(m, { settings: { a: 1 } })` → the settings landed. - A manifest with no `type` → the client threw, with code `VALIDATION_ERROR` and status 400. - In the same run, `packages-write-envelope.test.ts`, `client.test.ts` and `readme-package-install-example.test.ts` stayed green: 4 files, 222 tests. **The objectui caller, read by the seat at `.objectui-sha` `f8a9d0fb` (REWORK `5855224201`):** «The create path sends `POST /api/v1/packages` with `{ manifest: manifestBody }`, where `manifestBody = { ...draft, id, name, version, type: draft.type ?? 'app' }`.» The file is `packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx`. Studio's create-package dialog therefore always sends a declared `type`, and row 1b's refusal does not break it. ## Fixture triage (off-spec door fixtures, by disposition) - **Declaration added** (the fixture was never spec-legal; its subject is something else). Each fixture gains `type: 'app'`: - `package-door-namespace-conflict-code.test.ts` (the `manifest` helper); - `domain-handler-registry.test.ts` (the duplicate-id drive); - `packages-capability-gate.test.ts` (the install write row); - `http-dispatcher.test.ts` (the 409 and `?overwrite=true` cases). - **Spelling corrected.** In `http-dispatcher.test.ts`, the two `POST /packages install` cases changed `type: 'application'` to `'app'`. `'application'` is not a member of the declared enum. - **Replaced whole** (the pin asserted the very branch this PR removes): - `packages-install-enable-on-install.test.ts`. The case "the BARE body form does NOT honour the key" asserted `201` plus ENABLED. That was a standing pin on row 4, which the card believed had none. It now asserts `400 VALIDATION_ERROR` and that nothing installed. - `packages-install-preserves-lifecycle.test.ts`. The case "the BARE body form still sets nothing" now asserts `400 VALIDATION_ERROR`. It still asserts that the operator's disable stands in the registry and on disk. ## Pins (new file `packages/runtime/src/domains/packages-install-body-contract.test.ts`) Every refusal asserts `status` 400, `error.code` `VALIDATION_ERROR` and `success: false`. It also asserts that nothing was written: the id is absent from the registry, or the existing row is unchanged. - **§0** Every row body is refused by the declaration itself. - **§1** Row 1b, both forms. - **§2** Row 2, the in-manifest and bare positions. - **§3** Row 3: `'false'` is never installed-enabled, `'true'` is refused, and `overwrite: 'true'` is not read as absent. - **§4** Row 4, `enableOnInstall` / `overwrite` / `settings`. The message names the misplaced key, `"manifest"`, and `?overwrite=true`. - **§5** Parity for the top-level wrapped unknown key: the door answers what the declaration answers. The pin stays green whichever way the seam is decided. - **§6** Ordering. The `version` leg's sentence survives when a body also lacks `type`. An off-spec body answers 400, not 409, on an installed id. - **§7** Controls: both forms install, the manifest is stored as sent, wrapped `settings` land, and the SDK shapes behave as above. - **§8** The rest of the declaration, added in patch round 2. Nine cases each answer `400 VALIDATION_ERROR`, install nothing, and name the path: - no `name`, in the wrapped and the bare form; - a `namespace` outside the grammar; - a `scope` outside its set; - a non-string `description`; - the retired `capabilities`; - an unknown key inside `contributes`, inside a `data[]` seed, and inside `engines`. Each case is first asserted off-declaration by the declaration itself, beside a control that parses green and differs by the defect alone. ## Ablation (commit first, then mutate, then restore) - **Mutation.** At `e2c4c7d90`, `node scripts/ablation-replace.mjs` replaced the anchor `const declaredBody = PackageInstallBodySchema.safeParse(body);` with a verdict that always passes and hands back the raw body. The anchor count went 1 to 0, the replacement count 0 to 1, and the blob `4f6b4be7` to `4a881701`. - **No dist involved.** The subject is reached through a relative import (`../http-dispatcher.js` to `./domains/packages.ts`), so no `dist/` sits on the path. `ablation-dist-preflight --absent` confirms the marker is absent from runtime `dist/` and that the tree is clean. - **Red leg.** The three files went **13 failed, 42 passed**. Every row pin in §1–§4 failed, as did §6's 409-ordering case and both replaced bare-form pins. §0, §5, the `version`-leg ordering case and every §7 control stayed green. The direction was predicted before the run: red. - **Restore.** The tool restored with `git checkout HEAD`, then showed the blob after restore equals HEAD (`4f6b4be7`) and `git diff HEAD` is empty. Re-verified by hand, the tree is clean. - **Green leg.** The same three files then passed 55 of 55. ## Tests and gates (head `df0108189` unless stated; the patch rounds are recorded at the end of this section) - **Runtime door tests.** 31 door-touching test files, plus the uncommitted probe: **32 files, 779 tests passed**. - **Full runtime suite.** `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: **280 files, 3927 passed, 1 skipped**, at `0e3d3fdf5`, before any merge of `origin/main`. The branch has merged `origin/main` three times: - The first merge (`df0108189`, base `e7f69dbba`) brought no change to `packages/runtime`, the install schemas or the lockfile. - The second merge (`f2e2c99bd`, base `4df101c38`) brought the #19543 flow-list retirement: `domains/automation.ts`, `dispatcher-plugin.ts`, `route-ledger.ts`, `packages/client/src/index.ts`, seven runtime test files (`dispatcher-plugin.anonymous-gate.integration`, `domain-handler-registry`, `anonymous-gate-actions-automation`, `automation-run-read-permission-gate`, `automation-write-capability-gate`, `http-dispatcher.tenancy-posture-outage`, `http-dispatcher`) and `test-typecheck-debt.json`. It does not touch the install door, `PackageInstallBodySchema`, `ManifestSchema` or the install writers. 10 door and runtime test files re-ran on the merged tree at `481ad07f`: **515 passed**. - The third merge (`dd49317f1`, base `805af4f29`) brought no `packages/runtime` change and no lockfile change. - **Typecheck.** `pnpm --filter @objectstack/runtime typecheck` exited 0. Its test layer (`tsconfig.test.json`) compiles the new file; `--listFilesOnly` counts it. The debt ledger held at 27 files, 191 errors and 69 signatures. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 60 families: **59 exited 0**. The one other is `pnpm check:dual-build-cjs-loads`: **NOT MEASURED** (exit 3, `PREREQUISITE NOT MET`: 31 packages have no `dist/` without a full build, which is CI's). As a scoped substitute, `require('packages/runtime/dist/index.cjs')` loads (`HttpDispatcher` is a function). `--ran` reconciliation: 60 derived, 59 run, 1 NOT MEASURED, 0 UNRUN. - **Lint.** `pnpm lint`, the full `eslint . --no-inline-config`, not narrowed: exit 0. - **Issue citations.** `node scripts/check-issue-citations.mjs --base origin/main`: exit 0 (12 citations: 11 resolve, 1 cross-repo). - **Patch round 1 (head `481ad07f`, changeset only).** - `check-adr-0087-registration --base origin/main` → 0, reading `[BREAKING+clause-②-narrowing]`. - `check-changeset-no-major --base origin/main` → 0. - 10 door and runtime test files → 515 passed. - Derived gates: 60 derived, 59 exit 0, `check:dual-build-cjs-loads` NOT MEASURED (exit 3). `pnpm lint` → 0; the real issue-citation check → 0. - **Patch round 2 (head `1358147bc`: the changeset states the whole declaration, plus the §8 pins).** - `check-adr-0087-registration --base origin/main` → 0, reading `[BREAKING+clause-②-narrowing]` with the one marker. - `check-changeset-no-major --base origin/main` → 0. - The same 10 files → **524 passed** (515 plus the 9 §8 cases). - `pnpm --filter @objectstack/runtime typecheck` → 0. - Ablation, with the same anchor as before (the body parse replaced by a verdict that always passes): the body-contract file went 20 failed / 10 passed, and every §8 case answered `201` where it expects `400`, i.e. installed before the fix. The restore was byte-identical to HEAD. - Derived gates: 60 derived, 59 exit 0, `check:dual-build-cjs-loads` NOT MEASURED (exit 3; 36 packages have no `dist/`). `pnpm lint` → 0; the real issue-citation check → 0 (8 citations, all resolve). ## Changeset and bump `.changeset/19328-install-door-body-parse.md`, `@objectstack/runtime: minor`, carrying `Clause-②: no (narrowing)`, a **BREAKING for callers of the install door** paragraph with a FROM → TO remedy for each refused shape, and exactly one ADR-0087 `not-required (no-migration-prescription)` disposition. It was rewritten in patch round 1, after contract review `5855204061` answered FAIL on the bump and the seat's REWORK `5855224201` upheld it. The claim's own line stays `Clause-②: no`, because a claim line carries only `yes | no`. Why `minor` and `(narrowing)`: the diff shrinks the door's observed wire accept set. Bodies that answered `201` now answer `400`, and bare-form `overwrite: true` and `settings` were HONOURED before and are refused now. `scripts/pm/clause2-line.mjs` defines `no (narrowing)` as «NOT a widening, but breaking». AGENTS.md Post-Task Checklist step 3 makes `(narrowing)` BREAKING: the changeset must carry its migration, and must state its ADR-0087 disposition in writing. The same door's two earlier pull-backs to the same declaration carry exactly this form, and this PR narrows more than either: - `.changeset/19120-install-door-parses-manifest-version.md` (#19120, the `version` leg); - `.changeset/19417-install-door-parses-manifest-id.md` (#19417, the `id` leg). Each carries `minor`, `Clause-②: no (narrowing)`, a BREAKING paragraph and `adr-0087: not-required (no-migration-prescription)`. `node scripts/check-adr-0087-registration.mjs --base origin/main` now reads this changeset as `[BREAKING+clause-②-narrowing]` and accepts it with its marker (exit 0). For each refused shape, the changeset names what a client should send instead. ✅ **Settled in patch round 1.** The seat answered *Open question 2* with A (REWORK `5855224201`). The text below is kept as the record of why it was raised. The two earlier PRs on this same door, for #19120 (the `version` leg) and #19417 (the `id` leg), each pulled the door back to the same declaration. Both declared `Clause-②: no (narrowing)`, bumped `minor` and wrote an ADR-0087 `not-required` disposition. This PR narrows more than either did: row 4's bare `overwrite` and `settings` were **honoured** before and are refused now. ## Acceptance notes (observations, not filed) - **A JSON `null` body answers 500.** Through the dispatcher, a `null` or `undefined` body answers `500 INTERNAL_ERROR` with the TypeError text ("Cannot read properties of null (reading 'manifest')"). The cause is the unchanged `body.manifest || body` line ahead of the id gate. The Hono adapter maps an unparseable body to `{}`, but a literal JSON `null` would reach the door as `null`. That path is inferred from the adapter code, **not measured through a public door**, so it is not filed. Owner: none. - **`?overwrite` is compared by hand.** The query parameter is still read with a hand-written `=== 'true' || === true`, not with `parseBooleanParam`, which is the file's own declared query-boolean coercion. That code is outside this card's rows and unchanged here. Owner: none. - **Nested strip-mode shapes are stored as sent.** Some nested shapes are still in strip mode, and those are stored as the caller sent them. A walk of `ManifestSchema` at this head found them only in the object form of a navigation item's `visible` and its `meta`. The gate refuses unknown keys in the manifest and in every nested block the declaration closes: `contributes` and its `kinds[]`, `data[]` (`SeedSchema` is a `strictObject`), `navigationContributions[]` and their items, `engine`, `engines`, and the structured `permissions`. §8 pins three of these at the door. The earlier sentence here named `data[]` as strip mode and said the gate closed top-level keys only; both were wrong, and patch round 2 corrected them. Owner: none. ## Spec-side follow-up: filed as #20219 (the #19327 pattern; it lands with or right after this PR) `@objectstack/spec`'s `PackageInstallBodySchema` docblock goes stale when this lands. Four places are affected: - The "measured residual" list still records classes 1b, 2, 3 and 4 as answering `201`. - The paragraph on the two runtime door drives says they post bodies with no `type`; they now carry `type`. - The `enableOnInstall` docblock says the door "reads the raw body". - `package-api.test.ts` has a block, "the measured bare-form senders are the RESIDUAL", that makes the same claims. That docblock ships in `dist/api/index.d.ts`, per the #19327 changeset. `packages/spec/**` is read-only for this card, so this is the #19327 pattern: a spec-lane docs follow-up, not a rider here. ## Open questions 1. **The seam, row 2 at the top level of the wrapped form.** Should `PackageInstallRequestSchema` be closed (`.strict()`)? The door now asks the declaration, so it follows with no edit, and §5 stays green either way. - **For closing, measured:** no first-party producer sends an undeclared top-level key. The SDK sends `manifest`, `settings`, `enableOnInstall` and `overwrite`. objectui's `PackageFormDialog` sends `{ manifest }`. - **The cost of leaving it:** a misspelt option such as `enabledOnInstall: false` is silently dropped, and the package installs ENABLED. That is the same harm as row 3, reached by a typo. - **Why this PR does not do it:** the docblock ties the strip mode to maintainer ruling A (batch #148 item 4), and this card may not move a spec shape. So it is a spec-lane and maintainer decision. 2. **The changeset's `Clause-②` arm and bump.** ✅ Answered A by the seat (REWORK `5855224201`) and applied in patch round 1. The claim declares `Clause-②: no`, and this PR copies it. The same-door precedents (#19120, #19417) declared `no (narrowing)` with `minor`. Should the seat re-declare `no (narrowing)` with `minor` and an ADR-0087 `not-required` disposition? Recommended, because refusing previously honoured bare-form `overwrite` and `settings` is a narrowing of observed wire behaviour. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3cb84d0 commit c02fa12

9 files changed

Lines changed: 828 additions & 51 deletions
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime): `POST /api/v1/packages` parses the whole body through `PackageInstallBodySchema` instead of reading it key by key (#19328)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the install door.** The door now enforces the
10+
WHOLE install-body declaration. A body that `PackageInstallBodySchema` refuses
11+
is refused with `400` / `VALIDATION_ERROR` and installs nothing, whatever the
12+
reason. Every shape below used to answer `201`, and two of the install options
13+
used to be honoured. The list names the shapes a caller is most likely to have
14+
sent, and ends with one bullet for everything else the declaration states. For
15+
each one, change what you send FROM the refused shape TO the declared one:
16+
17+
- **A manifest with no `type`, in either body form.** FROM
18+
`{ "manifest": { "id": …, "name": …, "version": … } }` (or the same manifest
19+
sent bare) TO the same manifest with a declared `type`: one of `app`,
20+
`plugin`, `ui`, `driver`, `server`, `theme`, `agent`, `objectql`, `module`,
21+
`gateway` or `adapter`, e.g. `"type": "app"`. It used to install anyway.
22+
- **A manifest with no `name`, in either body form.** `name` is the manifest's
23+
other required key besides `id`, `version` and `type`. FROM
24+
`{ "manifest": { "id": …, "version": …, "type": … } }` (or the same manifest
25+
sent bare) TO the same manifest with `"name": "…"`, the human-readable
26+
package name. It used to install anyway, because the door parsed only the
27+
`id` and `version` legs.
28+
- **An unknown key inside the manifest, or on a bare body.** Example: a
29+
transposed `namesapce`. FROM a manifest carrying the undeclared key TO the
30+
manifest with that key removed, or spelled as the declared key it was meant
31+
to be (`namespace`). The refusal names the key. The key used to be STORED
32+
with the package.
33+
- **A string-typed `enableOnInstall` or `overwrite`.** FROM
34+
`"enableOnInstall": "false"` / `"overwrite": "true"` TO JSON booleans,
35+
`"enableOnInstall": false` / `"overwrite": true`. `'false'` used to install a
36+
fresh package ENABLED, which is the opposite of what the caller asked for.
37+
`'true'` for `overwrite` was read as absent, which answered `409` on an
38+
installed id.
39+
- **Install options spelled on the BARE form (`enableOnInstall`, `overwrite`,
40+
`settings`).** FROM `{ "id": …, …, "overwrite": true }` TO the wrapped form,
41+
`{ "manifest": { "id": …, … }, "enableOnInstall": …, "overwrite": …,
42+
"settings": … }`. `overwrite` may also go on the query string instead
43+
(`?overwrite=true`), which a bare body may keep using. Before this change the
44+
door handled these key by key: `enableOnInstall` was ignored, but
45+
**`overwrite: true` and `settings` were HONOURED** (an installed id was
46+
overwritten, and the settings reached the install). All three were also
47+
stored as manifest keys. They are refused now. This is the part of the
48+
change that removes behaviour a caller could have been relying on.
49+
- **Any other constraint `ManifestSchema` declares.** Each one is now enforced
50+
at this door. Before, only the `id` and `version` legs were, and the manifest
51+
was stored as sent. The constraints include:
52+
- the `namespace` grammar: 2–20 characters, starting with a lowercase
53+
letter, with only lowercase letters, digits and underscores;
54+
- the closed value sets of `scope` (`cloud`, `system`, `project`),
55+
`runtime` and `packaging`;
56+
- the declared type of a key (for example a non-string `description`, or a
57+
non-string version in `dependencies`);
58+
- the retired manifest keys `configuration`, `capabilities`, `extensions` and
59+
`loading`, which the declaration keeps only as named refusals;
60+
- unknown keys inside the nested blocks the declaration closes. These are
61+
`contributes` (and its `kinds[]`), `data[]` (each entry is a `SeedSchema`
62+
seed), `navigationContributions[]` (and their items), `engine`, `engines`,
63+
and the structured form of `permissions`.
64+
65+
FROM the off-declaration value TO the value the declaration states. The
66+
refusal names the path.
67+
68+
The accept set only shrinks back to what the published declaration has always
69+
said. `PackageInstallBodySchema` in `@objectstack/spec` is a union of two
70+
branches: the wrapped request, or a bare manifest as the whole body.
71+
`ManifestSchema` declares the manifest's required keys (`id`, `name`,
72+
`version`, `type`) and the grammar or value set of the rest. It closes the
73+
manifest, and the nested blocks listed above, against unknown keys.
74+
The wrapped request types `enableOnInstall` and `overwrite` as booleans. Its
75+
docblock says a bare manifest carries no install options: «a caller that needs
76+
an option sends the wrapped form». Until now, the door parsed only two legs of
77+
the manifest (`id` and `version`) and read every other key positionally off
78+
the raw body. That is «declared ≠ enforced» on a published API contract. Now
79+
the door parses the body once through the declared union and reads
80+
`overwrite`, `settings` and `enableOnInstall` off the parsed request. Nothing
81+
in `@objectstack/spec` moves, and neither branch of the union is relaxed.
82+
83+
**What is not affected.**
84+
85+
- A well-formed body installs exactly as before, in both forms and on both
86+
install limbs (the protocol primitive and the bare-registry fallback).
87+
- The first-party SDK (`client.packages.install(m, { enableOnInstall,
88+
overwrite, settings })`) already sends the wrapped form with JSON booleans.
89+
Studio's create-package dialog sends `{ manifest }` with a declared `type`.
90+
- The manifest is stored as sent, with no parse-time defaults added.
91+
- The refusals for a missing or malformed `id` and `version` keep their own
92+
sentences and still come first.
93+
- Every request-shape refusal is still answered ahead of the duplicate-id
94+
`409`.
95+
- Boot-time and in-process installs reach `SchemaRegistry.installPackage` /
96+
`ObjectQL.registerApp` directly and never pass through this door.
97+
98+
**Still accepted, because the declaration accepts it.** An unknown key at the
99+
top level of the wrapped form (`{ manifest, bogus }`) is dropped, not refused.
100+
`PackageInstallRequestSchema` is declared in strip mode, and the door now does
101+
exactly what the declaration says.
102+
103+
**If you are refused.** The refusal names the path of each failed constraint,
104+
and the form it read the body as. A misplaced install option also gets the wrapped form (and, for
105+
`overwrite`, `?overwrite=true`) spelled out. So the prescription arrives with
106+
the `400` rather than in a changelog.
107+
108+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. `objectstack migrate meta` has nothing to reach, because there is no old spelling that maps to a new one — every refused shape is one the published declaration already refused, and a caller repairs it by sending the declared shape (a declared `type`, a `name`, the declared key or value, a JSON boolean, the wrapped form). The refusal itself carries the remedy. -->

‎packages/runtime/src/domain-handler-registry.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,12 @@ describe('HttpDispatcher extracted domains (PR-5: packages)', () => {
597597
// keeps it, asserting the refusal this fixture used to assert the
598598
// acceptance of. `com.example.pkg-a` is the same name made publishable,
599599
// which is the repair `manifestIdRefusal` itself prescribes for it.
600-
const manifest = { id: 'com.example.pkg-a', name: 'A', version: '1.0.0' };
600+
//
601+
// [#19328] And `type`, the third key the door's whole-body parse reads:
602+
// `ManifestSchema` declares it required, so the `forced` limb's `201`
603+
// was unreachable without it — and the `dup` limb would answer `400`,
604+
// which is ordered ahead of the `409` this case is about.
605+
const manifest = { id: 'com.example.pkg-a', name: 'A', version: '1.0.0', type: 'app' };
601606
const dup = await dispatcher.dispatch('POST', '/packages', manifest, {}, {} as any);
602607
expect(dup.response?.status).toBe(409);
603608
const forced = await dispatcher.dispatch('POST', '/packages', manifest, { overwrite: 'true' }, {} as any);

‎packages/runtime/src/domains/packages-capability-gate.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,10 @@ const WRITE_ROUTES: WriteCase[] = [
145145
// `overwrite` so the allow-path clears the 409 duplicate guard (the shared
146146
// registry double answers `getPackage` truthy for any id); the write gate
147147
// runs FIRST, so the deny cases still 403/401 before this is consulted.
148-
{ name: 'POST /packages (install)', path: '/', method: 'POST', body: { manifest: { id: 'com.example.pkg-new', name: 'n', version: '1.0.0' } }, query: { overwrite: 'true' }, target: (_p, r) => r.installPackage },
148+
// [#19328] `type: 'app'` because the door now parses the whole body and
149+
// `ManifestSchema` declares `type` required — the allow-path must reach the
150+
// writer, not a 400 on the fixture.
151+
{ name: 'POST /packages (install)', path: '/', method: 'POST', body: { manifest: { id: 'com.example.pkg-new', name: 'n', version: '1.0.0', type: 'app' } }, query: { overwrite: 'true' }, target: (_p, r) => r.installPackage },
149152
{ name: 'PATCH /:id/enable', path: '/pkg-a/enable', method: 'PATCH', target: (_p, r) => r.enablePackage },
150153
{ name: 'PATCH /:id/disable', path: '/pkg-a/disable', method: 'PATCH', target: (_p, r) => r.disablePackage },
151154
{ name: 'POST /:id/publish-drafts', path: '/pkg-a/publish-drafts', method: 'POST', target: (p) => p.publishPackageDrafts },

0 commit comments

Comments
 (0)