Skip to content
19 changes: 19 additions & 0 deletions .changeset/24086-install-primitive-parses-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: `installPackage` judges the whole manifest by the install door's declaration, so the primitive refuses a manifest `POST /api/v1/packages` refuses

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No stored metadata moves and nothing is converted: the refused values arrive in a call's manifest argument, and stored `sys_packages` rows are untouched and keep hydrating as stored (the boot hydration does not judge `type` / `scope`). The only conversion that could exist (rewrite `application` to `app`, `environment` to `project`) would be a silent rewrite of what an author declared, which this change refuses on purpose, so there is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); the narrowing is the runtime accept set of a primitive that parses an existing published declaration, with no declaration or export changed (not type-surface-only or runtime-export-retired). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

`ObjectStackProtocolImplementation.installPackage` parsed only `manifest.id`, so an in-process caller stored manifests the install door answers `400 VALIDATION_ERROR` for. The primitive now parses the manifest through `PackageInstallManifestSchema` (`@objectstack/spec/api`, `ManifestSchema` with `scope` narrowed to `project`), the declaration the door parses, after its version default and before any write.

- **FROM:** `protocol.installPackage({ manifest })` with `type: 'application'`, `scope: 'environment'`, `scope: 'cloud'` / `'system'`, no `type`, or a key the manifest does not declare (such as a metadata collection, `pages`) registered the package and wrote its `sys_packages` row.
- **TO:** the call throws with `statusCode: 400` (an HTTP boundary answers `VALIDATION_ERROR`), the message names each refused key path (`manifest.type`, `manifest.scope`, …) with the declaration's own sentence, and neither the registry nor `sys_packages` is written. A malformed `manifest.id` still answers first, with its own sentence and repair.
- **Unchanged:** a manifest with no `version` still installs with `version: '0.1.0'`, a missing `namespace` is still derived from the id, and the manifest is stored as sent (the parse's `scope` / `defaultDatasource` defaults are not copied onto it).
- **Rows already stored** with `type: 'application'` / `scope: 'environment'` keep hydrating at boot, as stored. A new write of the same manifest, including a `duplicatePackage` of such a base, is refused.
- **The fix for a caller:** write a declared `type` (`app` for an application package) and leave `scope` out, or write `scope: 'project'`.
10 changes: 5 additions & 5 deletions packages/metadata-protocol/src/durable-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe('installPackage — durable persistence (#2532)', () => {
it('persists a VERSIONLESS manifest by defaulting version (the vanish-on-restart bug)', async () => {
const { impl, registryCalls, publish } = makeImpl();
const res: any = await (impl as any).installPackage({
manifest: { id: 'com.example.orders', name: '订单中心' },
manifest: { id: 'com.example.orders', name: '订单中心', type: 'app' },
});

// In-memory half.
Expand All @@ -111,7 +111,7 @@ describe('installPackage — durable persistence (#2532)', () => {

it('keeps an explicit version untouched', async () => {
const { impl, publish } = makeImpl();
await (impl as any).installPackage({ manifest: { id: 'com.example.v', version: '2.3.4' } });
await (impl as any).installPackage({ manifest: { id: 'com.example.v', name: 'V', type: 'app', version: '2.3.4' } });
expect((publish.mock.calls[0][0] as any).manifest.version).toBe('2.3.4');
});

Expand Down Expand Up @@ -208,7 +208,7 @@ describe("service-ABSENT host — the degraded path (#17676 ruling A' items 2/3)
const { impl, registryCalls, publish, registry } = makeImpl({ packageService: false });

const res: any = await (impl as any).installPackage({
manifest: { id: 'com.example.leave', name: '请假' },
manifest: { id: 'com.example.leave', name: '请假', type: 'app' },
});

// The door still answers 201 — which is exactly why the card calls the
Expand Down Expand Up @@ -282,7 +282,7 @@ describe("the restart split this card reported (#17676 ruling A' items 3/5)", ()
// ── process 1 ─────────────────────────────────────────────────────────
const before = makeImpl({ packageService: false, publish: sysPackages.publish });
await (before.impl as any).installPackage({
manifest: { id: 'com.example.leave', name: '请假' },
manifest: { id: 'com.example.leave', name: '请假', type: 'app' },
});
expect(before.registry.getPackage('com.example.leave')).toBeTruthy();
expect([...sysPackages.rows.keys()]).toEqual([]);
Expand Down Expand Up @@ -310,7 +310,7 @@ describe("the restart split this card reported (#17676 ruling A' items 3/5)", ()

const before = makeImpl({ publish: sysPackages.publish });
await (before.impl as any).installPackage({
manifest: { id: 'com.example.leave', name: '请假' },
manifest: { id: 'com.example.leave', name: '请假', type: 'app' },
});
// The durable half ran, so the restart has something to replay.
expect([...sysPackages.rows.keys()]).toEqual(['com.example.leave']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => {
const { impl, registryCalls, publish } = makeImpl();
await expect(
(impl as any).installPackage({
manifest: { id: 'com.acme.crm', version: '1.0.0', engines: { protocol: OLD } },
manifest: { id: 'com.acme.crm', name: 'CRM', type: 'app', version: '1.0.0', engines: { protocol: OLD } },
}),
).rejects.toBeInstanceOf(ProtocolIncompatibleError);
// The refusal happens BEFORE any registry write or durable persist.
Expand All @@ -47,7 +47,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => {
const { impl } = makeImpl();
try {
await (impl as any).installPackage({
manifest: { id: 'com.acme.crm', version: '1.0.0', engines: { protocol: OLD } },
manifest: { id: 'com.acme.crm', name: 'CRM', type: 'app', version: '1.0.0', engines: { protocol: OLD } },
});
throw new Error('expected installPackage to throw');
} catch (e) {
Expand All @@ -63,7 +63,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => {
it('installs a package that declares a compatible range', async () => {
const { impl, registryCalls } = makeImpl();
const res: any = await (impl as any).installPackage({
manifest: { id: 'com.acme.ok', version: '1.0.0', engines: { protocol: CURRENT } },
manifest: { id: 'com.acme.ok', name: 'OK', type: 'app', version: '1.0.0', engines: { protocol: CURRENT } },
});
expect(registryCalls).toHaveLength(1);
expect(res.package.status).toBe('installed');
Expand All @@ -74,7 +74,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => {
try {
const { impl, registryCalls } = makeImpl();
const res: any = await (impl as any).installPackage({
manifest: { id: 'com.acme.legacy', version: '1.0.0' },
manifest: { id: 'com.acme.legacy', name: 'Legacy', type: 'app', version: '1.0.0' },
});
expect(registryCalls).toHaveLength(1);
expect(res.package.status).toBe('installed');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ function makeHarness(opts: { publish?: (data: unknown) => Promise<unknown> } = {
metadata: JSON.stringify({ name: 'leave_ticket', label: 'Ticket' }),
}];
const packages = new Map<string, any>([
[SOURCE, { manifest: { id: SOURCE, name: 'Leave', namespace: 'leave', version: '1.0.0' } }],
[OWNER, { manifest: { id: OWNER, name: 'Taken', namespace: 'taken', version: '1.0.0' } }],
[SOURCE, { manifest: { id: SOURCE, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' } }],
[OWNER, { manifest: { id: OWNER, name: 'Taken', type: 'app', namespace: 'taken', version: '1.0.0' } }],
]);
const namespaceOwners = new Map<string, string[]>([['leave', [SOURCE]], ['taken', [OWNER]]]);
const thrown: unknown[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function makeDuplicateImpl(sourceId = 'com.example.leave') {
find: vi.fn(async () => rows),
registry: {
getPackage: vi.fn(() => ({
manifest: { id: sourceId, name: 'Leave', namespace: 'leave', version: '1.0.0' },
manifest: { id: sourceId, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' },
})),
installPackage: vi.fn((manifest: any) => {
installed.push(manifest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function makeProtocol(
seeded.filter((r) => matches(r, opts?.where ?? {}))),
registry: {
getPackage: vi.fn(() => ({
manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'application' },
manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'app' },
})),
installPackage: vi.fn(),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24086] The protocol install primitive judges the WHOLE manifest by the
* install door's declaration, `PackageInstallManifestSchema`.
*
* `installPackage` parsed only the `id` leg, so an in-process caller stored what
* `POST /api/v1/packages` answers `400 VALIDATION_ERROR` for: an off-spec
* `type` (`'application'`), an off-spec `scope` (`'environment'`), a platform
* scope (`'cloud'` / `'system'`, which that declaration narrows away from
* `ManifestSchema`), a missing `type`, an unknown key.
*
* Every refusal is pinned by its envelope — what an HTTP boundary answers for
* the throw (`resolveThrownHttpError`), status AND code — by the key path it
* names, and by the absence of both writes. The lit controls hold the other
* direction: the versionless manifest the primitive has completed since #2532
* still installs, and the gate stores the manifest as sent, not the parse's
* defaulted copy.
*/
import { describe, it, expect, vi } from 'vitest';
import { manifestIdRefusal } from '@objectstack/spec/kernel';
import { packageInstallScopeRefusal } from '@objectstack/spec/api';
import { resolveThrownHttpError } from '@objectstack/types';
import { ObjectStackProtocolImplementation } from './protocol.js';

function makeImpl() {
const registryCalls: Array<{ manifest: any }> = [];
const engine = {
registry: {
installPackage: (manifest: any) => {
registryCalls.push({ manifest });
return { manifest, status: 'installed', enabled: true };
},
},
find: async () => [],
};
const publish = vi.fn(async () => ({ success: true }));
const services = new Map<string, any>([['package', { publish }]]);
const impl = new ObjectStackProtocolImplementation(engine as never, () => services);
return { impl, registryCalls, publish };
}

/** The thrown refusal, or a failure naming what happened instead. */
async function refusalOf(run: () => Promise<unknown>): Promise<any> {
try {
await run();
} catch (e) {
return e;
}
throw new Error('expected the call to be refused, but it resolved');
}

const BASE = { id: 'com.example.leave', name: 'Leave', version: '1.0.0', type: 'app' } as const;

/** Manifests the door's declaration refuses, with the key path the refusal names. */
const REFUSED: ReadonlyArray<readonly [string, Record<string, unknown>, string]> = [
["an off-spec type 'application' (cloud's AI authoring)", { ...BASE, type: 'application' }, 'manifest.type'],
["an off-spec scope 'environment' (cloud's AI authoring)", { ...BASE, scope: 'environment' }, 'manifest.scope'],
["the platform scope 'cloud', which the install declaration narrows away", { ...BASE, scope: 'cloud' }, 'manifest.scope'],
["the platform scope 'system'", { ...BASE, scope: 'system' }, 'manifest.scope'],
['a manifest with no type at all', { id: BASE.id, name: BASE.name, version: BASE.version }, 'manifest.type'],
['an unknown key (a metadata collection on the manifest)', { ...BASE, pages: [] }, 'manifest'],
];

describe('[#24086] installPackage judges the whole manifest by PackageInstallManifestSchema', () => {
for (const [why, manifest, path] of REFUSED) {
it(`refuses ${why} — 400 VALIDATION_ERROR naming ${path}, and writes nothing`, async () => {
const { impl, registryCalls, publish } = makeImpl();
const err = await refusalOf(() => (impl as any).installPackage({ manifest }));
// What an HTTP boundary answers for this throw — the door's envelope.
expect(resolveThrownHttpError(err)).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
expect(err.message).toContain(`${path}`);
// Neither writer ran: not the in-memory registry, not the durable row.
expect(registryCalls).toHaveLength(0);
expect(publish).not.toHaveBeenCalled();
});
}

it("surfaces the declaration's own sentence for a platform scope, not a reworded one", async () => {
const { impl } = makeImpl();
const err = await refusalOf(() => (impl as any).installPackage({ manifest: { ...BASE, scope: 'cloud' } }));
expect(err.message).toContain(`manifest.scope: ${packageInstallScopeRefusal('cloud')}`);
});

it('the id leg still answers first, with its own sentence and repair', async () => {
const { impl } = makeImpl();
const err = await refusalOf(() => (impl as any).installPackage({
manifest: { ...BASE, id: 'com.example.my_erp', type: 'application' },
}));
expect(resolveThrownHttpError(err)).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
expect(err.message).toBe(manifestIdRefusal('manifest.id', 'com.example.my_erp'));
});

it('lit control — a VERSIONLESS manifest still installs with the defaulted version (#2532)', async () => {
const { impl, registryCalls, publish } = makeImpl();
const res: any = await (impl as any).installPackage({
manifest: { id: BASE.id, name: BASE.name, type: 'app' },
});
expect(res.package.status).toBe('installed');
expect(registryCalls).toHaveLength(1);
expect(registryCalls[0].manifest).toMatchObject({ id: BASE.id, version: '0.1.0', namespace: 'leave' });
expect(publish).toHaveBeenCalledTimes(1);
});

it("lit control — scope 'project' installs, and the gate stores the manifest as SENT (no parse defaults)", async () => {
const { impl, registryCalls } = makeImpl();
await (impl as any).installPackage({ manifest: { ...BASE, scope: 'project' } });
expect(registryCalls[0].manifest.scope).toBe('project');

const plain = makeImpl();
await (plain.impl as any).installPackage({ manifest: { ...BASE } });
// `PackageInstallManifestSchema` defaults `scope` and `defaultDatasource`;
// neither is copied onto what is stored.
expect(plain.registryCalls[0].manifest).not.toHaveProperty('scope');
expect(plain.registryCalls[0].manifest).not.toHaveProperty('defaultDatasource');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('[#19417] installPackage parses `manifest.id` through its declaration',
it(`lit control — '${id}' still installs`, async () => {
const { impl, registryCalls } = makeImpl();
const res: any = await (impl as any).installPackage({
manifest: { id, name: 'X', version: '1.0.0' },
manifest: { id, name: 'X', type: 'app', version: '1.0.0' },
});
expect(registryCalls).toHaveLength(1);
expect(registryCalls[0].manifest.id).toBe(id);
Expand Down Expand Up @@ -156,7 +156,7 @@ function makeDuplicateImpl(sourceId = 'com.example.leave') {
find: vi.fn(async () => rows),
registry: {
getPackage: vi.fn(() => ({
manifest: { id: sourceId, name: 'Leave', namespace: 'leave', version: '1.0.0' },
manifest: { id: sourceId, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' },
})),
installPackage: vi.fn((manifest: any) => {
installed.push(manifest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta
] as const)('fresh id, %s → 500 %s, no row, no namespace', async (_label, code, publish) => {
const { impl, registry } = makeImpl(publish);

const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave' } }));
const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave', type: 'app' } }));

expect(door(err)).toMatchObject({ status: 500, code });
// The driver's words stay on `cause` for the operator, never in the caller's sentence.
Expand All @@ -178,7 +178,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta
const priorContent = JSON.parse(JSON.stringify(registry.getPackage('com.example.leave')));

const err = await rejectionOf(impl.installPackage({
manifest: { id: 'com.example.leave', name: 'Leave v2', version: '2.0.0', namespace: 'leave' },
manifest: { id: 'com.example.leave', name: 'Leave v2', type: 'app', version: '2.0.0', namespace: 'leave' },
enableOnInstall: true,
}));

Expand All @@ -198,7 +198,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta
throw refusal;
});

const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave' } }));
const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave', type: 'app' } }));

expect(err).toBe(refusal);
expect(door(err)).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' });
Expand All @@ -208,7 +208,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta
it('CONTROL — a landed write answers the installed row, exactly as before', async () => {
const { impl, registry, publish } = makeImpl(async () => ({ success: true }));

const res = await impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave' } });
const res = await impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave', type: 'app' } });

expect(res.package.manifest.id).toBe('com.example.leave');
expect(registry.getPackage('com.example.leave')).toBe(res.package);
Expand Down
Loading
Loading