Skip to content

Commit 28ad7e4

Browse files
fix(spec)!: PackageInstallRequestSchema's wrapped branch refuses an unknown top-level key by name (#20277)
Fixes #20249 Clause-②: no (narrowing) Dispatched by the `domain:spec` seat 4 PM, session `session_01CiCTczDo7tGhafXjf61dUJ`, round 1, on claim `5857174683`. Ruling carried: decision batch #227 item 3, letter A (ruling record `5856869656`, maintainer 「同意」). ## What changed - **`PackageInstallRequestSchema`**, the wrapped branch of `PackageInstallBodySchema` (`packages/spec/src/api/package-api.zod.ts`): `z.object` (strip) became `strictObject`, with surface `this package install request`. An unknown top-level key is now refused by name, in the same envelope the manifest's and the bare form's unknown keys get. The key is named, a near-miss is offered the declared key, and the history sentence carries the remedy: remove the key, or spell the declared option it meant. There is no alias and no grace window. `{ manifest, enabledOnInstall: false }` now answers: ```text Unrecognized key(s) on this package install request: `enabledOnInstall`. Did you mean `enabledOnInstall` → `enableOnInstall`? Until this shape was closed, an unknown top-level key on the wrapped install body parsed green and was silently dropped, so a misspelled install option was ignored without a word — a caller who asked for the package DISABLED got it installed ENABLED. Remove the key, or spell it as the declared install option it meant; the declared keys are enumerated by `PackageInstallRequestSchema` (@objectstack/spec, api/package-api.zod.ts). ``` - **Docblock.** The sentence 「⛔ Do NOT close the wrapped branch with `.strict()` … (ruling A)」 and the asymmetry paragraph around it are gone. In their place are this ruling's citation and its reason. The retired premise, «the declaration must not refuse a body the door answers `201` to», held only while the door did not parse its body. Since `c02fa1276` the door answers what this union declares, so the premise is circular. The heading now reads "disjoint — and BOTH are closed". The request schema's own docblock gains a short "CLOSED" section that points at the union. - **The install door's code is not edited.** It refuses the body because it asks the declaration. Its install-branch COMMENT was made true in patch round 1 (`0b352e98a`, under claim amendment `5858369774`). It used to open 「⚠️ What the union does NOT refuse, deliberately」 and now opens 「⭐ The TOP LEVEL of the wrapped form is closed too」. It states that `PackageInstallRequestSchema` is a `strictObject`, that an unknown top-level key fails the union by name, and that the door answers `400` / `VALIDATION_ERROR` and installs nothing. It cites ruling record `5856869656`. Residual item 2 in `package-api.zod.ts` now cites `5856869656` inline. Both edits are comment-only: for both files, the parser's leaf-token stream (JSDoc excluded) and the comment-free print are identical at `5941e9aa5` and `0b352e98a`, and a positive control reads DIFFER. - **ADR-0087 D3 entry** `package-install-request-unknown-keys-refused` (`packages/spec/src/migrations/entries/semantic/18.…ts`), plus the regenerated `migrations/registry.ts` region. `spec-changes.json` and `docs/protocol-upgrade-guide.md` were regenerated and did not change, because those projections do not render major-18 entries yet. - **Changeset** `.changeset/20249-install-request-wrapped-strict.md`: - `@objectstack/spec` `minor`, with a **BREAKING for callers of the install door** banner. - `Clause-②: no (narrowing)` on its own line, and the ADR-0087 marker `registered package-install-request-unknown-keys-refused`. - The FROM → TO migration for a misspelled option and for any other key. - The first-party reach (the SDK, and the objectui dialog), with out-of-repo callers stated as NOT MEASURED. ## Pins - **Spec door** (`packages/spec/src/api/package-api.test.ts`, the wrapped-branch rows): - Three refusals: a misspelled `enabledOnInstall`, a private `_source`, and an unknown key beside declared options. Each asserts that the bound union refuses the body. Each also asserts the wrapped branch's single issue: code `unrecognized_keys`, path `[]`, `keys` equal to exactly that key, and the key named in the message. The misspelling also asserts the offered `enableOnInstall`. - Controls: `manifest` alone; `manifest` plus every declared option, with each value carried through. The option list is read off `.shape`, so a new option cannot slip past the fixture. - The old "the WRAPPED branch DROPS an unknown key" pin is replaced. - **Install door** (`packages/runtime/src/domains/packages-install-body-contract.test.ts`): - §5 was a PARITY assertion («door and declaration agree, whichever way that is»). It stays green whichever way the declaration goes, so it could not tell strict from strip. It is now a status pin. - `{ manifest, enabledOnInstall: false }` answers `400 VALIDATION_ERROR`, naming `` `enabledOnInstall` `` and offering `` `enableOnInstall` ``. It is not installed, and it is not recorded as disabled. - `_source` gets the same answer, and nothing is installed. - Control: the same body spelled `enableOnInstall: false` answers `201` and is DISABLED in the registry and in the durable record. - §0 gains both bodies. The file header's "What is deliberately NOT refused" section is rewritten to match. ## Evidence - **Base.** Branch cut from `e46218674`. `origin/main` `a9fb83ef0` was merged in before this PR as `5941e9aa5`. The merge was clean, `registry.ts` was regenerated, and `check:migration-registry` passed with 267 semantic entries. The build closure was then rebuilt and every run below was repeated at `5941e9aa5`. - **Build.** `turbo run build --filter='@objectstack/client^...' --filter=@objectstack/client`: 33/33 tasks successful. - **Tests** at `5941e9aa5`, all exit 0: - `@objectstack/spec` `--project local`: 550 files, 16244 passed, 1 todo. - `@objectstack/runtime` `--project local`: 281 files, 4019 passed, 1 skipped. - `@objectstack/client`: 50 files, 636 passed. It imports both schemas in `readme-package-install-example.test.ts`. - **Typecheck** (`tsc --noEmit` and `check:test-typecheck`): spec, runtime and client all exit 0. - **Ablation** at `469f5e3a7`, through `scripts/ablation-replace.mjs` in wrap mode with an EXIT/INT/TERM trap. The merge touched neither ablated file nor either pin file. - Mutation: the anchor `}).describe('Install package request'));` became `}).strip().describe('Install package request'));`. Anchor count went 1 → 0, the replacement 0 → 1, and the blob `c4f6b506b477` → `70f87496fc7b`. - Spec file: 3 failed and 81 passed. The failures were the three refusal pins (`the bound union refuses it: expected true to be false`); the controls stayed green. - Runtime file: 3 failed and 29 passed. §5's two refusal pins failed with `expected 201 to be 400`, so the door installed under strip mode, and §0's premise failed too. The §5 control stayed green. - Restore: the blob equals the HEAD blob (`c4f6b506b477`) and `git diff HEAD` is empty. - **Gates** (`dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `5941e9aa5`): 88 derived, 86 exit 0, and 2 NOT MEASURED because their prerequisite is not met (exit 3). `--ran` reconciliation reports 0 UNRUN. - `check:dual-build-cjs-loads` and `check:type-check-debt` need a whole-workspace build. CI runs them. - Among the gates that passed: `check-adr-0087-registration --base origin/main` (it reads this changeset as `[BREAKING+clause-②-narrowing] registered package-install-request-unknown-keys-refused (new here)`), `check-changeset-no-major`, `check-empty-changeset`, `check:api-surface`, `check:authorable-surface`, `check:docs`, `check:strictness-ledger`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:doc-authoring` and `check:nul-bytes`. - **Lint, narrowed on purpose** (`eslint --no-inline-config --format json` over the 5 changed `.ts` files): 5 files, 0 errors, 0 warnings. - `--print-config` resolves all five. The changeset `.md` is outside eslint's configured population. - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project` or `projectService`), so this diff cannot change the verdict on any file it does not touch. - Repo-wide `pnpm lint` is left to CI. ## Measured against the dispatch's assumptions 1. **Holds.** On this base the door parses the whole body through `PackageInstallBodySchema` and refuses a wrapped unknown key without any runtime source edit. The door pin in §5 is the evidence. 2. **Partly held.** §5 did not assert the strip. It asserted parity, which stays green in both directions, so it would not have flipped on its own. It is now a status pin, and the file is a test file only. 3. **Sentences touched in the `PackageInstallBodySchema` residual section** (#20219's region; #20219 is not addressed here): - Item 2 read "refused by name on the bare branch, silently dropped on the wrapped one, `201` either way". This change made "silently dropped on the wrapped one" false. The item now reads "refused by name on the bare branch and, since decision batch #227 item 3, on the wrapped one too, `201` either way". - "`201` either way", the "additionally answers `201` to five classes" lead-in, and items 1b, 3 and 4 were already stale before this PR. They are left exactly as they were for #20219's dev. - The replaced paragraphs above the residual section (the old heading, the asymmetry paragraph and the no-strict sentence) are this card's own item 2, not #20219's text. ## Acceptance notes - **A sentence this change makes false in a fenced file.** In `packages/runtime/src/domains/packages.ts`, around lines 1003 to 1011, the install branch comment says 「⚠️ What the union does NOT refuse, deliberately: an unknown key at the TOP LEVEL of the wrapped form. `PackageInstallRequestSchema` is a plain `z.object` (strip mode) and its docblock forbids closing it …」. **Resolved in this PR:** the seat widened the claim to that one comment block (amendment `5858369774`), and patch round 1 (`0b352e98a`) made it true; see What changed. - **Pre-existing stale pin text, not touched.** In `package-api.test.ts`, the test "the door answers 201 to all of them anyway — so this declaration is a SUBSET of the door" (the `DOOR_201_RESIDUALS` list) has been false since `c02fa1276`. It belongs with #20219's docblock class. This PR's diff does not change its truth. - **The card says "the four declared options".** The schema declares five: `settings`, `enableOnInstall`, `overwrite`, `platformVersion` and `artifactRef`. The control covers all five, read off `.shape`. - **The card cites the wording of #19120 / #19417 for the disposition.** Those two changesets carry `not-required (no-migration-prescription)`, not `registered`. This changeset follows the ruling's `registered` disposition, because its body carries a FROM → TO prescription and the gate refuses `no-migration-prescription` beside one. It borrows those two changesets' prose shape instead: the BREAKING banner, "What is not affected", and the remedy arriving with the `400`. - The seat updated this body at 2026-09-27T19:33Z after patch round 1 (dev report `5859104116`). The at-tier review is `5858576330` (PASS at `5941e9aa5`). - **Reach.** - SDK, re-read at this base: `client.packages.install` sends only `manifest`, `settings`, `enableOnInstall` and `overwrite`. - objectui dialog: taken from the ruling record (`{ manifest }`), measured by the dev who closed the door's other residuals. It was not re-measured here, because this session has no objectui checkout. - hotcrm at `2f7b2326`: zero install-door POST callers (a read-only grep). - Out-of-repo callers: NOT MEASURED. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a6a17b commit 28ad7e4

7 files changed

Lines changed: 364 additions & 57 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `PackageInstallRequestSchema`'s wrapped branch refuses an unknown top-level key by name (#20249)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the install door** — a WRAPPED install body
10+
(`{ manifest, … }`) that carries any top-level key the declaration does not
11+
name is now refused `400` / `VALIDATION_ERROR` at `POST /api/v1/packages`, and
12+
`PackageInstallRequestSchema` / `PackageInstallBodySchema` refuse it at parse.
13+
It used to parse green with the key silently DROPPED, and the door installed
14+
the package and answered `201`.
15+
16+
This one narrows the declaration itself. The manifest and the bare form (a
17+
manifest as the whole body) already refused an unknown key by name; the wrapped
18+
top level was the one position of the install contract still declared strip
19+
mode. The sharp case is a misspelled option: `{ manifest, enabledOnInstall:
20+
false }` had `enabledOnInstall` dropped, so the package was installed
21+
**ENABLED** — the caller's explicit `false` inverted, with no word said. The
22+
wrapped branch is now a `strictObject`, like the other two positions: one rule
23+
for the whole install contract (decision batch #227 item 3, letter A; ruling
24+
record `5856869656`). No alias and no grace window.
25+
26+
The install door needs no edit and gets none: since the door started parsing
27+
its whole body through `PackageInstallBodySchema`, it answers exactly what that
28+
declaration says, so the refusal reaches `POST /api/v1/packages` the moment the
29+
declaration moves. The same fact retires the sentence that had forbidden this
30+
close — «the declaration must not refuse a body the door answers `201` to» held
31+
only while the door did not parse its body.
32+
33+
**What is not affected.** A wrapped body carrying only `manifest` and the
34+
declared install options — `settings`, `enableOnInstall`, `overwrite`,
35+
`platformVersion`, `artifactRef` — parses and installs exactly as before, and so
36+
does a bare manifest. Boot-time and in-process installs reach
37+
`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass
38+
through this declaration.
39+
40+
**Reach, measured first-party.** The SDK's `client.packages.install` sends only
41+
`manifest`, `settings`, `enableOnInstall` and `overwrite`, and the objectui
42+
package dialog sends only `{ manifest }`; neither breaks.
43+
**Out-of-repo callers are NOT MEASURED** — there is no telemetry on them, so a
44+
caller that sends its own private top-level key (a trace id, a source tag) now
45+
gets a `400` naming that key. Check your own callers before upgrading rather
46+
than inheriting this result.
47+
48+
**Migration — FROM → TO.** The refusal names the key and, for a near-miss,
49+
offers the declared one, so the prescription arrives with the `400`:
50+
51+
- A misspelled option: FROM `{ "manifest": { … }, "enabledOnInstall": false }`
52+
TO `{ "manifest": { … }, "enableOnInstall": false }` — respell it as the
53+
declared option it meant.
54+
- Any other undeclared top-level key: FROM `{ "manifest": { … }, "_source":
55+
"studio" }` TO `{ "manifest": { … } }` — remove it. There is no place on this
56+
request to carry it.
57+
58+
It is registered as an ADR-0087 structured TODO rather than a conversion: an
59+
unknown key has no mapping target, and deleting it automatically would repeat
60+
the silent drop this change closes.
61+
62+
<!-- adr-0087: registered package-install-request-unknown-keys-refused -->

‎packages/runtime/src/domains/packages-install-body-contract.test.ts‎

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@
2727
* enforced too: `name`, the `namespace` grammar, the closed value sets, the
2828
* retired-key tombstones and the nested blocks it closes. §8 pins that.
2929
*
30-
* ## What is deliberately NOT refused — the declaration decides
30+
* ## The wrapped TOP LEVEL — refused too, because the declaration closed it
3131
*
32-
* An unknown key at the TOP LEVEL of the wrapped form. The wrapped branch is a
33-
* plain `z.object` (strip mode) and its docblock forbids closing it here, so
34-
* `{ manifest, bogus }` parses green with `bogus` dropped. ⛔ No case below
35-
* pins that as intended behaviour in either direction: §5 asserts the door
36-
* AGREES WITH THE DECLARATION on it, which stays green whichever way the
37-
* declaration is later decided.
32+
* An unknown key at the TOP LEVEL of the wrapped form was the one position
33+
* this door still admitted when the whole-body parse landed: the wrapped
34+
* branch was strip mode, so `{ manifest, enabledOnInstall: false }` — a
35+
* misspelled `enableOnInstall` — parsed green with the key dropped and
36+
* installed the package ENABLED. Decision batch #227 item 3, letter A (ruling
37+
* record `5856869656`) closed that branch in `@objectstack/spec`, and the door
38+
* followed with no edit of its own, because it asks the declaration. §5 pins
39+
* the refusal at this door: the status, the named key, and that nothing was
40+
* installed.
3841
*
3942
* ## Envelope
4043
*
@@ -170,6 +173,8 @@ describe('§0 the declaration is what the door is held to', () => {
170173
{ ...m, enableOnInstall: false },
171174
{ ...m, overwrite: true },
172175
{ ...m, settings: { a: 1 } },
176+
{ manifest: m, enabledOnInstall: false },
177+
{ manifest: m, _source: 'studio' },
173178
];
174179
for (const body of refused) {
175180
expect(PackageInstallBodySchema.safeParse(body).success, JSON.stringify(body)).toBe(false);
@@ -315,23 +320,58 @@ describe('§4 row 4 — install options on the BARE form are refused, naming the
315320
});
316321

317322
// ═══════════════════════════════════════════════════════════════════════
318-
// §5 — the door follows the declaration, including where it strips
323+
// §5 — the wrapped TOP LEVEL: refused, because the declaration closed it
319324
// ═══════════════════════════════════════════════════════════════════════
320325

321-
describe('§5 the door answers what the declaration answers', () => {
322-
it('an unknown TOP-LEVEL key on the wrapped form: door and declaration agree, whichever way that is', async () => {
323-
// ⛔ Deliberately a PARITY assertion, not a status pin. The wrapped
324-
// branch strips today, so both accept; if the declaration is ever
325-
// closed, both must refuse — and the door follows with no edit,
326-
// because it asks the declaration.
326+
describe('§5 an unknown TOP-LEVEL key on the wrapped form is refused by name, and nothing installs', () => {
327+
// A STATUS pin, ⛔ not the parity assertion this section used to hold
328+
// («door and declaration agree, whichever way that is»): parity stays green
329+
// when the declaration is re-opened, because both would then accept, so it
330+
// could not tell a closed branch from a strip-mode one. The ruling decided
331+
// the direction (decision batch #227 item 3, letter A), so the direction is
332+
// what is pinned. Each refusal case reads the declaration's verdict on the
333+
// same body too, AFTER the door's answer, so a regression reddens the door
334+
// assertion first and the trailing read names whether the declaration moved.
335+
336+
it('a MISSPELLED option — `enabledOnInstall: false` — is refused naming the key, ⛔ never installed ENABLED', async () => {
327337
const { registry, install } = door();
328-
const m = manifest('toplevel.unknown');
329-
const body = { manifest: m, bogus: 1 };
330-
const declared = PackageInstallBodySchema.safeParse(body).success;
338+
const m = manifest('toplevel.misspelled');
339+
const body = { manifest: m, enabledOnInstall: false };
331340
const r = await install(body);
332341

333-
expect(r.response?.status).toBe(declared ? 201 : 400);
334-
expect(registry.getPackage(m.id) !== undefined).toBe(declared);
342+
expectRefused(r);
343+
// The inversion this ruling is about: before it, this id was installed
344+
// and ENABLED with the caller's `false` dropped. Refused, it is neither.
345+
expect(registry.getPackage(m.id)).toBeUndefined();
346+
expect(persistedDisabled().has(m.id)).toBe(false);
347+
// Named subjects, ⛔ not prose: the key the caller wrote, and the
348+
// declared option the refusal offers in its place.
349+
expect(messageOf(r)).toContain('`enabledOnInstall`');
350+
expect(messageOf(r)).toContain('`enableOnInstall`');
351+
// Read LAST, so the door's own answer is what a regression turns red first.
352+
expect(PackageInstallBodySchema.safeParse(body).success, 'the declaration refuses it').toBe(false);
353+
});
354+
355+
it('a PRIVATE key — `_source` — is refused naming the key, and nothing installs', async () => {
356+
const { registry, install } = door();
357+
const m = manifest('toplevel.private');
358+
const body = { manifest: m, _source: 'studio' };
359+
const r = await install(body);
360+
361+
expectRefused(r);
362+
expect(registry.getPackage(m.id)).toBeUndefined();
363+
expect(messageOf(r)).toContain('`_source`');
364+
expect(PackageInstallBodySchema.safeParse(body).success, 'the declaration refuses it').toBe(false);
365+
});
366+
367+
it('control — the same body with the option spelled as declared installs, DISABLED as asked', async () => {
368+
const { registry, install } = door();
369+
const m = manifest('toplevel.control');
370+
const r = await install({ manifest: m, enableOnInstall: false });
371+
372+
expect(r.response?.status).toBe(201);
373+
expect(registry.getPackage(m.id)?.enabled).toBe(false);
374+
expect(persistedDisabled().has(m.id)).toBe(true);
335375
});
336376
});
337377

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,14 +1000,17 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
10001000
// declaration's own docblock forbids it («a caller that needs an
10011001
// option sends the wrapped form»).
10021002
//
1003-
// ⚠️ What the union does NOT refuse, deliberately: an unknown key at
1004-
// the TOP LEVEL of the wrapped form. `PackageInstallRequestSchema`
1005-
// is a plain `z.object` (strip mode) and its docblock forbids
1006-
// closing it, so `{ manifest, bogus }` parses green with `bogus`
1007-
// dropped — and this door, reading only parsed keys, now does
1008-
// exactly what the declaration says. Refusing it here without the
1009-
// declaration would make the published contract admit a body the
1010-
// door refuses; that is the declaration's decision to make.
1003+
// ⭐ The TOP LEVEL of the wrapped form is closed too — by the
1004+
// declaration, not by this door. `PackageInstallRequestSchema` is a
1005+
// `strictObject` (ruling record `5856869656`, decision batch #227
1006+
// item 3, letter A), so an unknown top-level key such as
1007+
// `{ manifest, enabledOnInstall: false }` fails the union by name,
1008+
// and this door answers `400` / `VALIDATION_ERROR` and installs
1009+
// nothing — with no line of its own for it: reading only parsed
1010+
// keys, it does exactly what the declaration says. ⛔ Never refuse or
1011+
// admit a key here that the declaration decides the other way: the
1012+
// published contract and this door would then answer differently,
1013+
// and which keys the body may carry is the declaration's decision.
10111014
//
10121015
// The verdict is answered after the `id` and `version` legs below,
10131016
// which keep their own published sentences; every later read (the

‎packages/spec/src/api/package-api.test.ts‎

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,7 @@ describe('#18058 — install contract bound to the live door', () => {
900900
});
901901
});
902902

903-
describe('the two declared body forms — disjoint, and only ONE of them closed', () => {
903+
describe('the two declared body forms — disjoint, and BOTH closed', () => {
904904
it('parses the WRAPPED form the client SDK sends', () => {
905905
const body = { manifest: SDK_MANIFEST, settings: undefined, enableOnInstall: true };
906906
expect(PackageInstallBodySchema.safeParse(body).success).toBe(true);
@@ -933,23 +933,94 @@ describe('#18058 — install contract bound to the live door', () => {
933933
});
934934

935935
/**
936-
* ⭐ The WRAPPED branch is `z.object`, i.e. STRIP mode — it is NOT closed.
936+
* ⭐ The WRAPPED branch is CLOSED — an unknown top-level key is refused by
937+
* name, as the manifest's and the bare form's unknown keys are (decision
938+
* batch #227 item 3, letter A; ruling record `5856869656`).
937939
*
938-
* An earlier revision of the docblock claimed both branches were closed.
939-
* They are not, and the asymmetry is the DOOR's behaviour: the handler
940-
* reads `manifest`, `settings`, `enableOnInstall` and `overwrite` and
941-
* ignores every other key, so dropping an unknown one is exactly what it
942-
* does with it. ⛔ Closing this branch with `.strict()` would refuse bodies
943-
* the door answers `201` to — the direction ruling A forbids — so what is
944-
* pinned here is the drop, not a refusal.
940+
* What this pinned before was the opposite: the wrapped branch was strip
941+
* mode, so `{ manifest, enabledOnInstall: false }` parsed green with the
942+
* misspelled key DROPPED, and the door installed the package ENABLED — the
943+
* caller's explicit `false` inverted. Each refusal is read at BOTH doors of
944+
* this declaration: the union the install route is bound to, and the
945+
* wrapped branch alone, whose issue is the one the install door locates
946+
* and surfaces.
945947
*/
946-
it('the WRAPPED branch DROPS an unknown key rather than refusing it', () => {
947-
const verdict = PackageInstallBodySchema.safeParse({ manifest: SDK_MANIFEST, bogus: 1 });
948-
expect(verdict.success).toBe(true);
949-
expect(verdict.data && 'bogus' in verdict.data).toBe(false);
948+
describe('the WRAPPED branch refuses an unknown top-level key by name', () => {
949+
/** The wrapped branch's own verdict: one `unrecognized_keys` issue, at the top level, naming the key. */
950+
function expectRefusedByName(body: Record<string, unknown>, key: string) {
951+
expect(PackageInstallBodySchema.safeParse(body).success, 'the bound union refuses it').toBe(false);
952+
const verdict = PackageInstallRequestSchema.safeParse(body);
953+
expect(verdict.success).toBe(false);
954+
const issues = verdict.error?.issues ?? [];
955+
expect(issues).toHaveLength(1);
956+
expect(issues[0]?.code).toBe('unrecognized_keys');
957+
expect(issues[0]?.path).toEqual([]);
958+
expect((issues[0] as { keys?: string[] }).keys).toEqual([key]);
959+
expect(issues[0]?.message).toContain(`\`${key}\``);
960+
return issues[0]!.message;
961+
}
962+
963+
it('a MISSPELLED option — `enabledOnInstall` — is refused, and the declared key is offered', () => {
964+
const message = expectRefusedByName({ manifest: SDK_MANIFEST, enabledOnInstall: false }, 'enabledOnInstall');
965+
// The remedy's named subject: the option the caller meant.
966+
expect(message).toContain('`enableOnInstall`');
967+
});
968+
969+
it('a PRIVATE key — `_source` — is refused by name, not carried and not dropped', () => {
970+
expectRefusedByName({ manifest: SDK_MANIFEST, _source: 'studio' }, '_source');
971+
});
972+
973+
it('an unknown key BESIDE declared options is refused too — the options do not buy it a pass', () => {
974+
expectRefusedByName(
975+
{ manifest: SDK_MANIFEST, enableOnInstall: false, overwrite: true, bogus: 1 },
976+
'bogus',
977+
);
978+
});
979+
});
980+
981+
describe('controls — every declared wrapped body still round-trips', () => {
982+
/** A value for EVERY declared install option, each one the schema accepts. */
983+
const DECLARED_OPTIONS = {
984+
settings: { apiKey: 'abc123' },
985+
enableOnInstall: false,
986+
overwrite: true,
987+
platformVersion: '3.2.0',
988+
artifactRef: {
989+
url: 'https://marketplace.objectstack.io/artifacts/com.acme.crm/1.0.0.tgz',
990+
sha256: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
991+
size: 1024000,
992+
// Spelled out: the artifact reference DEFAULTS it, and a round-trip
993+
// fixture must be one the parse hands back unchanged.
994+
format: 'tgz',
995+
uploadedAt: '2026-02-01T10:00:00Z',
996+
},
997+
};
998+
999+
it('the fixture names every declared option — read off the shape, never listed by hand', () => {
1000+
const declared = Object.keys(PackageInstallRequestSchema.shape).filter((k) => k !== 'manifest');
1001+
expect(Object.keys(DECLARED_OPTIONS).sort()).toEqual(declared.sort());
1002+
});
1003+
1004+
it('`manifest` alone parses green at both doors', () => {
1005+
expect(PackageInstallRequestSchema.safeParse({ manifest: SDK_MANIFEST }).success).toBe(true);
1006+
expect(PackageInstallBodySchema.safeParse({ manifest: SDK_MANIFEST }).success).toBe(true);
1007+
});
1008+
1009+
it('`manifest` plus every declared option parses green, each option value carried through', () => {
1010+
const body = { manifest: SDK_MANIFEST, ...DECLARED_OPTIONS };
1011+
const request = PackageInstallRequestSchema.safeParse(body);
1012+
expect(request.error?.issues ?? []).toEqual([]);
1013+
const union = PackageInstallBodySchema.safeParse(body);
1014+
expect(union.error?.issues ?? []).toEqual([]);
1015+
for (const parsed of [request.data, union.data] as Array<Record<string, unknown> | undefined>) {
1016+
for (const [key, value] of Object.entries(DECLARED_OPTIONS)) {
1017+
expect(parsed?.[key], key).toEqual(value);
1018+
}
1019+
}
1020+
});
9501021
});
9511022

952-
it('lit control: the BARE branch IS closed — the same unknown key is refused there', () => {
1023+
it('the BARE branch is closed too — the same unknown key is refused there', () => {
9531024
// `ManifestSchema` is a `strictObject`, so this is a refusal, not a drop.
9541025
expect(PackageInstallBodySchema.safeParse({ ...SDK_MANIFEST, bogus: 1 }).success).toBe(false);
9551026
});

0 commit comments

Comments
 (0)