Skip to content

Commit aad1603

Browse files
fix(spec): PackageInstallResponseSchema declares the bare installed row the install door serves (#30593)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuV5cRafodM2YQHxeDkdiWJM)_ domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P Fixes #30464 Clause-②: yes (narrowing) — `PackageInstallResponseSchema` now accepts the bare installed row the door serves, which it refused, and refuses the `{ package }` wrapper it accepted; `PackageInstallResponse['data']` loses `package`, `dependencyResolution`, `namespaceConflicts` and `message`. No export is added, removed or renamed. Metadata-protocol: none — the shape of an API answer, not anything a metadata author writes. Skills-docs: none — `git grep -nE 'PackageInstallResponse|data\.package\b' -- skills/ content/docs/ ':!content/docs/references'` has 0 hits; the `data.package` reads in `packages/` and `ui/` source are other answers (override declarations, the permission-set clone, the marketplace detail), none the install answer. ## 维护者速读 - 改了什么:已发布的 API 契约里「安装包」接口(`POST /api/v1/packages`)的应答形状改成服务器实际返回的样子:`data` 就是安装后的包记录本身,不再声明一个从未发送过的 `{ package, … }` 外层包装。服务器和官方客户端的行为都不变。 - 为什么改:照着契约写集成代码的人会读 `data.package`,实际拿到 `undefined`;官方客户端早已按真实返回改过(#12034),只剩已发布契约是错的。依赖它的 #30412 要在这个形状上决定安装门是否加提示。 - 风险与回滚:只改 spec 里一个应答 schema 和由它生成的文档,服务器代码不动。按旧契约读 `data.package` 等四个成员的 TypeScript 代码升级后会编译报错(这些代码运行时本来就读到 undefined),changeset 写明了改法。回滚即撤本 PR。 - 先看:`packages/spec/src/api/package-api.zod.ts` 的 `PackageInstallResponseSchema`(`data` 那一行);`packages/client/src/packages-write-envelope.test.ts` 的 `#30464` 块(真实安装门的应答原样交给契约解析);`.changeset/30464-install-response-bare-row.md`。 - 两分钟验证:`pnpm --filter @objectstack/client exec vitest run src/packages-write-envelope.test.ts`,预期 6 条全绿;把 `data` 换回 main 上的包装(连同 `dropped-refinements.baseline.json` 里那三行)并重建 spec 后,`#30464` 块的 2 条转红。 - 需要你做的:无,席位落地。 ## What changes The published contract follows the wire; the door is not changed. Triage audit 6658640900: "The published contract is wrong, not the door … The change corrects the published schema to match the wire". - **`packages/spec/src/api/package-api.zod.ts`**: `PackageInstallResponseSchema.data` is `InstalledPackageSchema`, reused rather than restated: the `InstalledPackage` row `SchemaRegistry.installPackage` recorded, which the door answers as `success(pkg)` with status `201` on both install arms. The docblock says why, names the authoring stage of the row (this door computes no `writable` / `disableable`), and names the pin. The type names `PackageInstallResponse` / `PackageInstallResponseParsed` and the `PackageApiContracts.installPackage` binding are unchanged. - **`packages/spec/src/api/package-api.test.ts`**: the two cases that pinned the wrapper are replaced by three: the bare row parses; the `{ package }` wrapper is refused at `data.manifest` and `data.source`; `PackageApiContracts.installPackage.output` is this schema. - **`packages/client/src/packages-write-envelope.test.ts`**: a contract-vs-wire block. A real `SchemaRegistry` and the real `HttpDispatcher.handlePackages('', 'POST', …)` produce the 201 body, which goes untouched to `PackageApiContracts.installPackage.output.safeParse`: it parses green, `data` equals the registry's row and the parse strips no key the door sent; the wrapper built from the door's own row is refused. Nothing in the block writes the expected body by hand. - **`packages/spec/dropped-refinements.baseline.json`**: the three `api/PackageInstallResponse` sites move from `data.package.manifest.…` to `data.manifest.…`: the same three refinements, their path moved with the shape (the spec build gates this ledger). - **Regenerated**: `content/docs/references/api/package-api.mdx`; `docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md` (`api/` 501 → 499 sites: the wrapper object and the conflict-entry object are gone). - **Changeset** (`@objectstack/spec`: `patch`) with both declaration lines, the FROM → TO mapping, the consumer fix and the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Tests Head `3789cc39e` (this branch merged with `origin/main` at `c23eafa88`). <details><summary>Package suites, typecheck, eslint</summary> - `pnpm --filter @objectstack/spec test`: Test Files 815 passed (815), Tests 25644 passed, 1 todo. - `pnpm --filter @objectstack/client test`: Test Files 54 passed (54), Tests 699 passed. The pin file alone: 6 passed (6). - `pnpm --filter @objectstack/runtime test`: Test Files 543 passed (543), Tests 8655 passed, 19 skipped. The suites that drive the install door (`handlePackages('', 'POST', …)`): the eight `packages/runtime/src/domains/packages-install-*.test.ts`, `packages-ensure-customization.test.ts`, `http-dispatcher.test.ts`. - `pnpm --filter @objectstack/spec run typecheck`: exit 0 (test layer: 52 files / 240 errors / 131 signatures held in the existing debt ledger, unchanged). `pnpm --filter @objectstack/client run typecheck`: exit 0 (test layer 0 / 0 / 0). - eslint (`--no-inline-config`) over the three changed TypeScript files: exit 0, no findings. </details> <details><summary>Dogfood battery (owed by the narrowing arm)</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock, after a full workspace build (`turbo run build`, 119/119 tasks): `VERDICT command-exit 0`; Test Files 420 passed, 3 skipped (423); Tests 3496 passed, 36 skipped (3532); no failure. </details> <details><summary>Ablation: main's wrapper put back on `data`</summary> - Mutation: `scripts/ablation-replace.mjs` replaced the `data:` line with main's `{ package, dependencyResolution?, namespaceConflicts?, message? }` object (anchor 1 → 0, marker `ABLATION_30464`) and moved the three ledger sites back to `data.package.manifest.…` (anchor 3 → 0): a two-file mutation, because the spec build gates `dropped-refinements.baseline.json`. Spec rebuilt; `ablation-dist-preflight` read the marker in 2 built files. - Under the mutation: client pin 2 failed, 4 passed (6), exactly the two `#30464` cases; spec pin 2 failed, 96 passed (98), the bare-row and wrapper-refusal cases. - Restore: both paths back to their HEAD blobs, `git diff HEAD` empty; spec rebuilt; preflight `--absent` (marker absent from all 592 built files, tree clean). Client pin 6 passed (6); spec pin 98 passed (98). - The first mutation attempt is void: it moved only the schema, the spec build refused on the ledger, the preflight read the marker ABSENT from `dist/`, and its client reading was discarded. </details> ## Acceptance notes - **#30412 depends on this shape.** That card (`domain:server#6`) has the install door answer an optional `protocolCheckSkipped` notice, as the install-local door does. With this PR the declared `data` is the bare `InstalledPackage` row, so where that notice sits (on the envelope beside `data`, or elsewhere) is decided on that card against this shape; there is no wrapper left to add a member to. `packages/runtime/**` is untouched in this PR. - **Declarations changed from the claim.** The claim carries `Clause-②: yes` and `Metadata-protocol: semantic`. Measured and read against the rules, this PR declares `yes (narrowing)` and `none`, at `patch`: `semantic` is "the meaning or default of an authorable key", and this schema is an API answer; the narrowing half (the wrapper refused, four type members gone) is real on the published `@objectstack/spec/api` surface. Both pairs pass `check-changeset-no-major` and `check:adr-0087-registration`, which do not judge the act; `none` with `minor` exits 1 (`none-minor`). The seat updates the claim lines. - **ADR-0087 vocabulary gap, noted.** `type-surface-only` is closed to a narrowing whose schema lives in `packages/spec` (predicate 2, no-spec-diff), and `runtime-interface-only` to a `*.zod.ts`, so a spec API-answer narrowing whose affected party is a TypeScript consumer has no exact category. It carries `no-migration-prescription`, which the gate admits because its detector reads no arrow or framed table in the consumer line; the marker says so and cites D8. - **`InstallPackageResult`** (`packages/spec/src/contracts/package-service.ts`) still declares `{ package, dependencyResolution?, namespaceConflicts?, message? }`. It is the in-process `IPackageService` result, not the HTTP answer, and this PR does not touch it. ## Verification Head `3789cc39e`. <details><summary>Derived gate families: 102 run, reconciled</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 102 commands for 7 paths vs merge base `c23eafa88`. Each was run with its exit code captured before any pipe; `--ran` answers "102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: all 102 carry an exit code). - First pass: 100 exit 0, 2 exit 3 (PREREQUISITE NOT MET, nothing measured). `check-plugin-teardown-shape --self-test` needed a positive-control commit this shallow clone lacked: fetched at depth 1, rerun exit 0 (48 cases). `check:dual-build-cjs-loads` needed 32 more packages' `dist/`: full build, rerun exit 0 (116 entries across 71 packages load). - `check:skill-examples`: the first run exits 3, PREREQUISITE NOT MET, because `packages/client-react/dist` held no `.d.ts`. After `pnpm --filter @objectstack/client-react build`: exit 0, "283 prose examples type-check across 3 surface(s)". - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. - Local-only families (spec build touched): `check:dev-prereqs` exit 0 after the full build (107 artifacts present); `check:skill-frame-freshness` exit 0. </details> <details><summary>Declaration gates</summary> - `check-changeset-no-major.mjs --base origin/main --event` with a `pull_request` payload carrying this body's lines: exit 0, "LEVEL AXIS: … a non-protocol change at `patch`". - `check-adr-0087-registration.mjs --base origin/main`: exit 0, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [clause-②-narrowing] not-required (no-migration-prescription)". - Control: the claim's pair (`yes` / `semantic` / `minor`) also exits 0 on both gates, and `none` beside the old `minor` exits 1 (`none-minor`). The gates check the declarations against the levels; which pair is true is read from the diff (Acceptance notes). </details> <details><summary>Outside the derived total</summary> - Four CI-only families take a value from the workflow (`$RUNNER_TEMP`, the shard matrix) and have no local invocation: `check-issue-citations.mjs --census`, `check-shard-attestation.mjs` (dogfood, dogfood-verify) and `check-test-completeness.mjs`. NOT MEASURED locally; read on this PR's check-runs. - `origin/main` moved 15 commits past the merge base (to `48b3ae6eb`) after the merge. None touches this diff's files or the install answer: the two inside its packages are the seed-apply catch in `packages/runtime/src/domains/packages.ts` and the seed `landed` describe in `packages/spec/src/api/protocol.zod.ts`. </details> --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6701c08 commit aad1603

7 files changed

Lines changed: 152 additions & 65 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `PackageInstallResponseSchema` declares the installed row that `POST /api/v1/packages` really answers
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
**BREAKING** accept-set narrowing at the install answer contract (`@objectstack/spec`): `PackageInstallResponseSchema` refuses the `{ package }` wrapper it accepted, and `PackageInstallResponse['data']` no longer declares `package`, `dependencyResolution`, `namespaceConflicts` or `message`.
11+
12+
`POST /api/v1/packages` answers `{ success: true, data: <row> }` with status `201`, where the row is the `InstalledPackage` the registry recorded. `PackageInstallResponseSchema` (the output `PackageApiContracts.installPackage` binds) declared `data: { package, dependencyResolution?, namespaceConflicts?, message? }` instead. No surface sent that wrapper, so code written against the contract read `data.package` and got `undefined`. The first-party client already followed the wire: `client.packages.install` resolves to `InstalledPackage`.
13+
14+
- **FROM** `data: { package: InstalledPackage, dependencyResolution?, namespaceConflicts?, message? }` **TO** `data: InstalledPackage`. The name, the type names (`PackageInstallResponse`, `PackageInstallResponseParsed`) and the binding are unchanged. The server's answer is unchanged.
15+
- **Both directions.** The schema now accepts the bare row the door serves, which it refused, and refuses the `{ package }` wrapper no server sends. The type `PackageInstallResponse['data']` loses `package`, `dependencyResolution`, `namespaceConflicts` and `message`, so TypeScript code that reads them stops compiling. That code read `undefined` at runtime already.
16+
- **Fix for a consumer:** read the row from `data` itself. Replace `response.data.package` with `response.data`. Drop any read of `data.dependencyResolution`, `data.namespaceConflicts` or `data.message` on this route: the door never sent them.
17+
- **Level.** `patch`: this is the shape of an API answer, not anything a metadata author writes.
18+
19+
<!-- adr-0087: not-required (no-migration-prescription) an API answer shape, not metadata: no stored row or authored source holds it, so objectstack migrate meta has nothing to rewrite. The consumer line above is for integrator source code, which the compiler now flags (ADR-0087 D8); type-surface-only is closed to it only because the schema lives in packages/spec -->

‎content/docs/references/api/package-api.mdx‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ Install package response
461461
| **success** | `boolean` | ✅ | Operation success status |
462462
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
463463
| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata |
464-
| **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | |
464+
| **data** | `{ manifest: object; source: Enum<'code' \| 'marketplace' \| 'authored'>; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; … }` | ✅ | The installed package row, served bare as `data`: the row the registry recorded, at the authoring stage |
465465

466466
### Nested Shape: `PackageInstallResponse.error`
467467

@@ -490,10 +490,19 @@ Install package response
490490

491491
| Property | Type | Required | Description |
492492
| :--- | :--- | :--- | :--- |
493-
| **package** | `{ manifest: object; source: Enum<'code' \| 'marketplace' \| 'authored'>; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; … }` | ✅ | Installed package details |
494-
| **dependencyResolution** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | optional | Dependency resolution result |
495-
| **namespaceConflicts** | `{ type: 'namespace_conflict'; requestedNamespace: string; conflictingPackageId: string; conflictingPackageName: string; … }[]` | optional | Namespace conflicts detected |
496-
| **message** | `string` | optional | Installation status message |
493+
| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` |
494+
| **source** | `Enum<'code' \| 'marketplace' \| 'authored'>` | ✅ | Where the installed package came from: code (registered by the deployment's own code), marketplace (installed from the marketplace), or authored (a database-authored package) |
495+
| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error |
496+
| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled |
497+
| **installedAt** | `string` | optional | Installation timestamp |
498+
| **updatedAt** | `string` | optional | Last update timestamp |
499+
| **installedVersion** | `string` | optional | Currently installed version for quick access |
500+
| **previousVersion** | `string` | optional | Version before the last upgrade |
501+
| **statusChangedAt** | `string` | optional | Status change timestamp |
502+
| **errorMessage** | `string` | optional | Error message when status is error |
503+
| **settings** | `Record<string, any>` | optional | User-provided configuration settings |
504+
| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history |
505+
| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package |
497506

498507

499508
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 501 |
22+
| `api/` | 499 |

‎packages/client/src/packages-write-envelope.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ import { tmpdir } from 'node:os';
6868
import { join } from 'node:path';
6969
import { SchemaRegistry } from '@objectstack/objectql';
7070
import { HttpDispatcher } from '@objectstack/runtime';
71+
// The route map ships from `/api-assembled` (its read responses embed the
72+
// assembled package body); the install output it binds is declared in `/api`.
73+
import { PackageApiContracts } from '@objectstack/spec/api-assembled';
7174
import { ObjectStackClient } from './index';
7275

7376
const BASE_URL = 'http://localhost:3000';
@@ -232,3 +235,61 @@ describe('#12034 — the packages write verbs answer the bare row, not `{ packag
232235
expect(await client.packages.enable(MANIFEST.id)).toEqual(body.data);
233236
});
234237
});
238+
239+
/**
240+
* [#30464] The published contract for `POST /api/v1/packages`, held against
241+
* what the door really answers.
242+
*
243+
* The half above pins the CLIENT's declaration to the wire. This half pins the
244+
* SPEC's: `PackageApiContracts.installPackage.output` is what an integrator
245+
* generates a client from, and it declared `data: { package, … }` — a wrapper
246+
* no surface sends, so code written against it read `data.package` and got
247+
* `undefined`. The contract now follows the wire (the door was not changed).
248+
*
249+
* Same in-process chain as above: a real `SchemaRegistry`, the real
250+
* `HttpDispatcher` answering it, and the dispatcher's own body handed to the
251+
* declared schema untouched. Nothing here writes the expected body out by
252+
* hand, which is how the false declaration survived in the first place.
253+
*/
254+
describe('#30464 — PackageApiContracts.installPackage.output parses the install door\'s real answer', () => {
255+
async function installThroughTheDoor() {
256+
const registry = new SchemaRegistry();
257+
const { dispatcher } = dispatcherBackedClient(registry);
258+
const raw = await dispatcher.handlePackages('', 'POST', { manifest: MANIFEST }, {}, CONTEXT());
259+
return { registry, status: raw.response?.status, body: raw.response?.body as any };
260+
}
261+
262+
it('the door\'s 201 body parses green, and the parse keeps every key the door sent', async () => {
263+
const { registry, status, body } = await installThroughTheDoor();
264+
expect(status).toBe(201);
265+
266+
const parsed = PackageApiContracts.installPackage.output.safeParse(body);
267+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
268+
269+
// The declared `data` IS the row the registry recorded, and no key the
270+
// door sent is stripped by the parse. (`toMatchObject`, not `toEqual`:
271+
// the parsed value also carries the manifest defaults the declaration
272+
// applies — `scope`, `defaultDatasource` — which is the ADR-0122
273+
// `Parsed` shape, not a residue.)
274+
expect(body.data).toEqual(registry.getPackage(MANIFEST.id));
275+
expect(parsed.data!.data).toMatchObject(body.data);
276+
expect(parsed.data!.data.manifest.id).toBe(MANIFEST.id);
277+
});
278+
279+
it('the old `{ package }` reading is refused by the contract, and absent from the wire', async () => {
280+
const { body } = await installThroughTheDoor();
281+
282+
expect('package' in body.data).toBe(false);
283+
expect('message' in body.data).toBe(false);
284+
285+
// The wrapper the contract used to promise, built from the door's own
286+
// row, no longer passes it: the declaration and the wire cannot both
287+
// be read as true.
288+
const wrapped = { ...body, data: { package: body.data } };
289+
const parsed = PackageApiContracts.installPackage.output.safeParse(wrapped);
290+
expect(parsed.success).toBe(false);
291+
expect(parsed.error!.issues.map((i) => i.path.join('.'))).toEqual(
292+
expect.arrayContaining(['data.manifest', 'data.source']),
293+
);
294+
});
295+
});

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -593,9 +593,9 @@
593593
},
594594
"api/PackageInstallResponse": {
595595
"sites": [
596-
"data.package.manifest.data.element.records.element.valueType",
597-
"data.package.manifest.navigationContributions.element.items.element.lazy",
598-
"data.package.manifest.navigationContributions.element.items.element.lazy.options[0]"
596+
"data.manifest.data.element.records.element.valueType",
597+
"data.manifest.navigationContributions.element.items.element.lazy",
598+
"data.manifest.navigationContributions.element.items.element.lazy.options[0]"
599599
]
600600
},
601601
"api/PackageUpgradeRequest": {

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

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -201,53 +201,38 @@ describe('PackageInstallRequestSchema', () => {
201201
});
202202

203203
describe('PackageInstallResponseSchema', () => {
204-
it('should accept a successful install response', () => {
205-
const result = PackageInstallResponseSchema.parse({
206-
success: true,
207-
data: {
208-
package: {
209-
manifest: { id: 'com.acme.crm', name: 'acme_crm', version: '1.0.0', type: 'plugin' },
210-
source: 'authored',
211-
status: 'installed',
212-
enabled: true,
213-
},
214-
dependencyResolution: {
215-
dependencies: [
216-
{ packageId: 'com.acme.core', requiredRange: '^1.0.0', status: 'satisfied', resolvedVersion: '1.2.0' },
217-
],
218-
canProceed: true,
219-
requiredActions: [],
220-
installOrder: ['com.acme.core', 'com.acme.crm'],
221-
},
222-
message: 'Package installed successfully',
223-
},
224-
});
204+
// The row `POST /api/v1/packages` answers as `data` — bare, no wrapper. The
205+
// wire half (the real dispatcher's answer parsed with this schema) is pinned
206+
// in `packages/client/src/packages-write-envelope.test.ts`.
207+
const ROW = {
208+
manifest: { id: 'com.acme.crm', name: 'acme_crm', version: '1.0.0', type: 'plugin' },
209+
source: 'authored',
210+
status: 'installed',
211+
enabled: true,
212+
installedAt: '2026-11-05T00:00:00.000Z',
213+
};
214+
215+
it('accepts the installed row itself as data', () => {
216+
const result = PackageInstallResponseSchema.parse({ success: true, data: ROW });
225217
expect(result.success).toBe(true);
226-
expect(result.data.package.status).toBe('installed');
227-
expect(result.data.dependencyResolution?.canProceed).toBe(true);
218+
expect(result.data.manifest.id).toBe('com.acme.crm');
219+
expect(result.data.source).toBe('authored');
220+
expect(result.data.status).toBe('installed');
221+
expect(result.data.enabled).toBe(true);
228222
});
229223

230-
it('should accept install response with namespace conflicts', () => {
231-
const result = PackageInstallResponseSchema.parse({
224+
it('refuses the { package } wrapper no surface sends, at data.manifest and data.source', () => {
225+
const result = PackageInstallResponseSchema.safeParse({
232226
success: true,
233-
data: {
234-
package: {
235-
manifest: { id: 'com.acme.crm', name: 'acme_crm', version: '1.0.0', type: 'plugin' },
236-
source: 'authored',
237-
status: 'installed',
238-
enabled: true,
239-
},
240-
namespaceConflicts: [{
241-
type: 'namespace_conflict',
242-
requestedNamespace: 'acme',
243-
conflictingPackageId: 'com.other.plugin',
244-
conflictingPackageName: 'Other Plugin',
245-
suggestion: 'acme_crm',
246-
}],
247-
},
227+
data: { package: ROW, message: 'Package installed successfully' },
248228
});
249-
expect(result.data.namespaceConflicts).toHaveLength(1);
250-
expect(result.data.namespaceConflicts![0].suggestion).toBe('acme_crm');
229+
expect(result.success).toBe(false);
230+
const paths = result.error!.issues.map((i) => i.path.join('.'));
231+
expect(paths).toEqual(expect.arrayContaining(['data.manifest', 'data.source']));
232+
});
233+
234+
it('is the output the installPackage contract binds', () => {
235+
expect(PackageApiContracts.installPackage.output).toBe(PackageInstallResponseSchema);
251236
});
252237
});
253238

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

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -522,22 +522,35 @@ export type PackageInstallBody = z.input<typeof PackageInstallBodySchema>;
522522
export type PackageInstallBodyParsed = z.infer<typeof PackageInstallBodySchema>;
523523

524524
/**
525-
* Response after installing a package.
525+
* Response after installing a package (`POST /api/v1/packages`): the envelope,
526+
* with the installed row itself as `data`.
527+
*
528+
* ## `data` is the BARE row the door serves
529+
*
530+
* The install door answers `success(pkg)` with status `201`, where `pkg` is the
531+
* `InstalledPackage` row `SchemaRegistry.installPackage` recorded, on both
532+
* install arms (the protocol service's and the no-protocol fallback). So `data`
533+
* is {@link InstalledPackageSchema}, reused rather than restated.
534+
*
535+
* Until #30464 this declared `data: { package, dependencyResolution?,
536+
* namespaceConflicts?, message? }`, a wrapper no surface ever sent: an
537+
* integrator coding against it read `data.package` and got `undefined`. The
538+
* first-party client had already followed the wire (#12034:
539+
* `client.packages.install` resolves to `InstalledPackage`). ⛔ The fix is the
540+
* contract following the wire, never the door growing the old envelope.
541+
*
542+
* The row is at the AUTHORING stage, because this door installs the manifest
543+
* its own request schema ({@link PackageInstallBodySchema}) declares. It carries
544+
* no `writable` / `disableable`: those are the READ doors' per-read verdicts
545+
* (`InstalledPackageAtEitherStageSchema`, `./package-api-assembled.zod.ts`),
546+
* which this door does not compute.
547+
*
548+
* Contract-vs-wire pin (the real dispatcher's answer parsed with
549+
* `PackageApiContracts.installPackage.output`):
550+
* `packages/client/src/packages-write-envelope.test.ts`.
526551
*/
527552
export const PackageInstallResponseSchema = lazySchema(() => BaseResponseSchema.extend({
528-
data: z.object({
529-
package: InstalledPackageSchema.describe('Installed package details'),
530-
dependencyResolution: DependencyResolutionResultSchema.optional()
531-
.describe('Dependency resolution result'),
532-
namespaceConflicts: z.array(z.object({
533-
type: z.literal('namespace_conflict').describe('Error type'),
534-
requestedNamespace: z.string().describe('Requested namespace'),
535-
conflictingPackageId: z.string().describe('Conflicting package ID'),
536-
conflictingPackageName: z.string().describe('Conflicting package name'),
537-
suggestion: z.string().optional().describe('Suggested alternative'),
538-
})).optional().describe('Namespace conflicts detected'),
539-
message: z.string().optional().describe('Installation status message'),
540-
}),
553+
data: InstalledPackageSchema.describe('The installed package row, served bare as `data`: the row the registry recorded, at the authoring stage'),
541554
}).describe('Install package response'));
542555
export type PackageInstallResponse = z.input<typeof PackageInstallResponseSchema>;
543556
/** Post-parse shape of {@link PackageInstallResponse} — defaults applied, transforms run (ADR-0122). */

0 commit comments

Comments
 (0)