diff --git a/.changeset/adr-0087-p0-protocol-handshake.md b/.changeset/adr-0087-p0-protocol-handshake.md new file mode 100644 index 0000000000..884fb86d12 --- /dev/null +++ b/.changeset/adr-0087-p0-protocol-handshake.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-core": minor +"@objectstack/metadata-protocol": minor +--- + +ADR-0087 P0 — enforce the protocol version handshake (make `engines.protocol` real). + +`PluginEnginesSchema.protocol` (ADR-0025 §3.2, protocol-first per §3.10 #3) was declared, documented, and checked by no loader or installer — an ADR-0078 "declarable-but-inert" violation. A package built against an incompatible protocol major failed deep in a schema `.parse()` or a renderer contract instead of at the boundary. + +- **`@objectstack/spec`**: exports `PROTOCOL_VERSION` / `PROTOCOL_MAJOR` (`kernel`) — the single source of truth the handshake checks against. A drift test keeps it in lockstep with the package major. +- **`@objectstack/metadata-core`**: adds `checkProtocolCompat()` (pure, major-grained range check), `assertProtocolCompat()`, and the structured `ProtocolIncompatibleError` (`OS_PROTOCOL_INCOMPATIBLE`, carrying both versions and the `objectstack migrate meta --from N` command). It refuses only on a *positive* mismatch determination; absent ranges are grandfathered (warn) and unrecognized ranges never cause a false rejection. +- **`@objectstack/metadata-protocol`**: `installPackage` runs the handshake before writing to the registry — an incompatible package is refused with a machine-actionable diagnostic instead of crashing later. + +Additive and backward compatible: packages that declare no `engines.protocol` range keep loading (with a warning). Part of the ADR-0087 epic (#2643); resolves #2644. diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 3ec0603460..e9343ddbe1 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -13,4 +13,5 @@ export * from './repository.js'; export * from './in-memory-repository.js'; export * from './cache.js'; export * from './layered-repository.js'; +export * from './protocol-handshake.js'; export * from './objects/index.js'; diff --git a/packages/metadata-core/src/protocol-handshake.test.ts b/packages/metadata-core/src/protocol-handshake.test.ts new file mode 100644 index 0000000000..9d3e395f02 --- /dev/null +++ b/packages/metadata-core/src/protocol-handshake.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; +import { + assertProtocolCompat, + checkProtocolCompat, + ProtocolIncompatibleError, + rangeAdmitsMajor, +} from './protocol-handshake.js'; + +const RT = '11.0.0'; // runtime protocol version used across these tests + +describe('rangeAdmitsMajor', () => { + it('caret pins the major', () => { + expect(rangeAdmitsMajor('^11', 11)).toBe(true); + expect(rangeAdmitsMajor('^11.2.0', 11)).toBe(true); + expect(rangeAdmitsMajor('^10', 11)).toBe(false); + expect(rangeAdmitsMajor('^12', 11)).toBe(false); + }); + + it('tilde pins the major', () => { + expect(rangeAdmitsMajor('~11.4.0', 11)).toBe(true); + expect(rangeAdmitsMajor('~10.9.9', 11)).toBe(false); + }); + + it('bare exact / bare major', () => { + expect(rangeAdmitsMajor('11', 11)).toBe(true); + expect(rangeAdmitsMajor('11.0.0', 11)).toBe(true); + expect(rangeAdmitsMajor('10', 11)).toBe(false); + }); + + it('wildcard forms', () => { + expect(rangeAdmitsMajor('*', 11)).toBe(true); + expect(rangeAdmitsMajor('latest', 11)).toBe(true); + expect(rangeAdmitsMajor('11.x', 11)).toBe(true); + expect(rangeAdmitsMajor('10.x', 11)).toBe(false); + }); + + it('single comparators', () => { + expect(rangeAdmitsMajor('>=11', 11)).toBe(true); + expect(rangeAdmitsMajor('>=12', 11)).toBe(false); + expect(rangeAdmitsMajor('>=10.0.0', 11)).toBe(true); + expect(rangeAdmitsMajor('<13', 11)).toBe(true); + expect(rangeAdmitsMajor('<11', 11)).toBe(false); + expect(rangeAdmitsMajor('>11', 11)).toBe(false); // bare major excludes 11 + expect(rangeAdmitsMajor('>11', 12)).toBe(true); + }); + + it('compound comparator ranges', () => { + expect(rangeAdmitsMajor('>=11.0.0 <13.0.0', 11)).toBe(true); + expect(rangeAdmitsMajor('>=11.0.0 <13.0.0', 12)).toBe(true); + expect(rangeAdmitsMajor('>=11.0.0 <13.0.0', 13)).toBe(false); + expect(rangeAdmitsMajor('>=12 <14', 11)).toBe(false); + }); + + it('hyphen ranges', () => { + expect(rangeAdmitsMajor('10.0.0 - 12.0.0', 11)).toBe(true); + expect(rangeAdmitsMajor('10.0.0 - 12.0.0', 13)).toBe(false); + }); + + it('returns null for unrecognized shapes (never a false rejection)', () => { + expect(rangeAdmitsMajor('', 11)).toBeNull(); + expect(rangeAdmitsMajor('garbage', 11)).toBeNull(); + expect(rangeAdmitsMajor('workspace:*', 11)).toBeNull(); + }); + + it('bounds pathological input (ReDoS-safe) without a slow scan', () => { + // The engines string is externally authored; the comparator/hyphen parsing + // must not degrade on adversarial input (CodeQL alerts 837/838). + const overlong = '<' + '\t'.repeat(100_000); + const hyphenBomb = 'a\t-\t' + '\t'.repeat(100_000); + const start = performance.now(); + expect(rangeAdmitsMajor(overlong, 11)).toBeNull(); + expect(rangeAdmitsMajor(hyphenBomb, 11)).toBeNull(); + expect(rangeAdmitsMajor('>=11.0.0 ' + ' '.repeat(100_000) + '<13.0.0', 11)).toBeNull(); + expect(performance.now() - start).toBeLessThan(50); + }); +}); + +describe('checkProtocolCompat', () => { + it('ok when the declared protocol range admits the runtime major', () => { + const r = checkProtocolCompat({ id: 'a', engines: { protocol: '^11' } }, RT); + expect(r.status).toBe('ok'); + }); + + it('incompatible with a structured diagnostic when it does not', () => { + const r = checkProtocolCompat({ id: 'com.acme.crm', engines: { protocol: '^10' } }, RT); + expect(r.status).toBe('incompatible'); + if (r.status !== 'incompatible') return; + expect(r.diagnostic.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(r.diagnostic.packageId).toBe('com.acme.crm'); + expect(r.diagnostic.requiredRange).toBe('^10'); + expect(r.diagnostic.rangeSource).toBe('engines.protocol'); + expect(r.diagnostic.runtimeVersion).toBe(RT); + expect(r.diagnostic.targetMajor).toBe(10); + expect(r.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 10'); + // The message names both versions and the command — the whole point of D1. + expect(r.diagnostic.message).toContain('^10'); + expect(r.diagnostic.message).toContain('11.0.0'); + expect(r.diagnostic.message).toContain('migrate meta --from 10'); + }); + + it('the diagnostic is identical in shape one major behind or five', () => { + const near = checkProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT); + const far = checkProtocolCompat({ id: 'p', engines: { protocol: '^6' } }, RT); + expect(near.status).toBe('incompatible'); + expect(far.status).toBe('incompatible'); + if (near.status !== 'incompatible' || far.status !== 'incompatible') return; + expect(Object.keys(near.diagnostic).sort()).toEqual(Object.keys(far.diagnostic).sort()); + expect(far.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 6'); + }); + + it('protocol-first precedence over platform and legacy engine', () => { + // protocol wins even when platform/legacy would say otherwise + const r = checkProtocolCompat( + { id: 'p', engines: { protocol: '^11', platform: '^10' }, engine: { objectstack: '^9' } }, + RT, + ); + expect(r.status).toBe('ok'); + if (r.status !== 'ok') return; + expect(r.source).toBe('engines.protocol'); + }); + + it('falls back to platform, then legacy engine.objectstack', () => { + const viaPlatform = checkProtocolCompat({ id: 'p', engines: { platform: '^11' } }, RT); + expect(viaPlatform.status).toBe('ok'); + if (viaPlatform.status === 'ok') expect(viaPlatform.source).toBe('engines.platform'); + + const viaLegacy = checkProtocolCompat({ id: 'p', engine: { objectstack: '>=10' } }, RT); + expect(viaLegacy.status).toBe('ok'); + if (viaLegacy.status === 'ok') expect(viaLegacy.source).toBe('engine.objectstack'); + }); + + it('no-range when nothing is declared (grandfathering)', () => { + expect(checkProtocolCompat({ id: 'p' }, RT).status).toBe('no-range'); + expect(checkProtocolCompat({ id: 'p', engines: {} }, RT).status).toBe('no-range'); + }); + + it('unparsed-range for a present but unrecognized range (no false rejection)', () => { + const r = checkProtocolCompat({ id: 'p', engines: { protocol: 'workspace:*' } }, RT); + expect(r.status).toBe('unparsed-range'); + }); +}); + +describe('assertProtocolCompat', () => { + it('throws ProtocolIncompatibleError on a positive mismatch', () => { + expect(() => + assertProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT, () => {}), + ).toThrow(ProtocolIncompatibleError); + try { + assertProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT, () => {}); + } catch (e) { + expect(e).toBeInstanceOf(ProtocolIncompatibleError); + expect((e as ProtocolIncompatibleError).code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect((e as ProtocolIncompatibleError).diagnostic.migrateCommand).toContain('--from 10'); + } + }); + + it('returns silently on ok', () => { + const warn = vi.fn(); + expect(() => assertProtocolCompat({ id: 'p', engines: { protocol: '^11' } }, RT, warn)).not.toThrow(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns (does not throw) on no-range', () => { + const warn = vi.fn(); + assertProtocolCompat({ id: 'p' }, RT, warn); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]![0]).toContain('no engines.protocol range'); + }); + + it('warns (does not throw) on an unparsed range', () => { + const warn = vi.fn(); + assertProtocolCompat({ id: 'p', engines: { protocol: '???' } }, RT, warn); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]![0]).toContain('unrecognized'); + }); +}); diff --git a/packages/metadata-core/src/protocol-handshake.ts b/packages/metadata-core/src/protocol-handshake.ts new file mode 100644 index 0000000000..6a78938586 --- /dev/null +++ b/packages/metadata-core/src/protocol-handshake.ts @@ -0,0 +1,301 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Protocol version handshake (ADR-0087 D1). + * + * A package declares the metadata/runtime protocol range it was authored + * against via its manifest `engines.protocol` (ADR-0025 §3.2, protocol-first + * per §3.10 #3; falling back to `engines.platform`, then the legacy + * `engine.objectstack`). Until now that field was **declared but checked + * nowhere** — an ADR-0078 "declarable-but-inert" violation — so a package + * built against protocol N loaded by an incompatible runtime failed deep in a + * schema `.parse()` or a renderer contract instead of at the boundary. + * + * This module is that boundary. It turns a version mismatch into a + * **structured, machine-actionable refusal** carrying a stable code, both + * versions, and the exact replay command — the same shape whether the consumer + * is one major behind or five (ADR-0087: timeliness is never load-bearing). + * + * It never blocks on a range it cannot parse: it only refuses on a *positive* + * determination that the declared range excludes the runtime major. Absent and + * unrecognized ranges are admitted (the former grandfathered, the latter a + * parser-coverage gap) so the handshake can never cause a false rejection. + */ + +import { PROTOCOL_VERSION } from '@objectstack/spec/kernel'; +import { MetadataError } from './errors.js'; + +/** The manifest slice the handshake reads. All fields optional. */ +export interface ProtocolHandshakeManifest { + id?: string; + version?: string; + engines?: { protocol?: string; platform?: string }; + /** Legacy single-field compatibility declaration, superseded by `engines`. */ + engine?: { objectstack?: string }; +} + +export type ProtocolCompatResult = + | { status: 'ok'; runtimeMajor: number; requiredRange: string; source: RangeSource } + | { status: 'no-range'; runtimeMajor: number } + | { status: 'unparsed-range'; runtimeMajor: number; requiredRange: string; source: RangeSource } + | { + status: 'incompatible'; + runtimeMajor: number; + runtimeVersion: string; + requiredRange: string; + source: RangeSource; + /** Stable, machine-readable diagnostic (also the shape emitted as JSON). */ + diagnostic: ProtocolIncompatibleDiagnostic; + }; + +export type RangeSource = 'engines.protocol' | 'engines.platform' | 'engine.objectstack'; + +export interface ProtocolIncompatibleDiagnostic { + code: 'OS_PROTOCOL_INCOMPATIBLE'; + packageId: string; + requiredRange: string; + rangeSource: RangeSource; + runtimeVersion: string; + runtimeMajor: number; + /** The declared major the package targets, when a single major is determinable. */ + targetMajor: number | null; + /** The command that resolves the refusal (wired end-to-end by ADR-0087 P2). */ + migrateCommand: string; + message: string; +} + +/** + * Structured error thrown at the install/load boundary for an incompatible + * package. Extends `MetadataError` so callers can `instanceof` across package + * boundaries and read `.code`; `.diagnostic` carries the JSON-serializable + * detail for `--json` output and the MCP surface (ADR-0087 D4). + */ +export class ProtocolIncompatibleError extends MetadataError { + constructor(public readonly diagnostic: ProtocolIncompatibleDiagnostic) { + super(diagnostic.code, diagnostic.message); + } +} + +/** First declared range, protocol-first (ADR-0025 §3.10 #3). */ +function resolveDeclaredRange( + manifest: ProtocolHandshakeManifest, +): { range: string; source: RangeSource } | null { + const protocol = manifest.engines?.protocol?.trim(); + if (protocol) return { range: protocol, source: 'engines.protocol' }; + const platform = manifest.engines?.platform?.trim(); + if (platform) return { range: platform, source: 'engines.platform' }; + const legacy = manifest.engine?.objectstack?.trim(); + if (legacy) return { range: legacy, source: 'engine.objectstack' }; + return null; +} + +/** Leading integer of a version-ish token (`11`, `11.2`, `11.2.3`, `v11`). */ +function leadingMajor(token: string): number | null { + const m = token.trim().replace(/^v/i, '').match(/^(\d+)/); + return m ? Number.parseInt(m[1]!, 10) : null; +} + +/** + * Decide whether a SemVer-ish range admits `runtimeMajor`. + * + * Returns `true`/`false` on a positive determination, or `null` when the range + * shape is not recognized (caller admits with a warning rather than refusing). + * Protocol compatibility is major-grained by design, so every supported form + * reduces to "which majors does this admit". + */ +export function rangeAdmitsMajor(range: string, runtimeMajor: number): boolean | null { + const r = range.trim(); + if (r === '' ) return null; + // A real SemVer range is short. Bounding the length here defuses any + // pathological input before it reaches a regex (a manifest's `engines` + // string is externally authored, so treat it as untrusted): an overlong + // string is simply unrecognized (admit-with-warning), never a slow scan. + if (r.length > 128) return null; + if (r === '*' || r === 'x' || r === 'latest') return true; + + // Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined). + const parts = r.split(/[\s,]+/).filter(Boolean); + if (parts.length > 1 && parts.every((p) => /^[<>]=?/.test(p))) { + let ok = true; + for (const p of parts) { + const admits = comparatorAdmitsMajor(p, runtimeMajor); + if (admits === null) return null; + ok = ok && admits; + } + return ok; + } + + // Hyphen range: "11.0.0 - 12.0.0". Split on the whitespace-delimited hyphen + // (a fixed anchor between the two `\s+` runs — linear, no `.`-vs-`\s` + // backtracking) rather than a lazy `(.+?)…(.+)` match. + const hyphenParts = r.split(/\s+-\s+/); + if (hyphenParts.length === 2) { + const lo = leadingMajor(hyphenParts[0]!); + const hi = leadingMajor(hyphenParts[1]!); + if (lo === null || hi === null) return null; + return runtimeMajor >= lo && runtimeMajor <= hi; + } + + // Single comparator. + if (/^[<>]=?/.test(r)) return comparatorAdmitsMajor(r, runtimeMajor); + + // Caret: `^N` / `^N.x.y` pins the major (for N >= 1, which all protocol + // majors are). + if (r.startsWith('^')) { + const maj = leadingMajor(r.slice(1)); + return maj === null ? null : runtimeMajor === maj; + } + + // Tilde: `~N.x.y` also pins the major for the handshake's purposes. + if (r.startsWith('~')) { + const maj = leadingMajor(r.slice(1)); + return maj === null ? null : runtimeMajor === maj; + } + + // `N.x` / `N.*` wildcard. + const wildcard = r.match(/^(\d+)\.(?:x|\*)/i); + if (wildcard) return runtimeMajor === Number.parseInt(wildcard[1]!, 10); + + // Bare exact version or bare major: `11`, `11.0.0`. + const exact = leadingMajor(r); + if (exact !== null && /^\d+(\.\d+){0,2}$/.test(r)) return runtimeMajor === exact; + + return null; +} + +function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolean | null { + // Peel the operator off by fixed prefix rather than a `\s*(.+)` match, whose + // whitespace/any-char overlap is a polynomial-ReDoS shape on untrusted input. + let op: string; + let rest: string; + if (comparator.startsWith('>=') || comparator.startsWith('<=')) { + op = comparator.slice(0, 2); + rest = comparator.slice(2); + } else if (comparator.startsWith('>') || comparator.startsWith('<')) { + op = comparator.slice(0, 1); + rest = comparator.slice(1); + } else { + return null; + } + const bound = rest.trim().replace(/^v/i, ''); + if (bound === '') return null; + const parts = bound.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); + if (!parts) return null; + const maj = Number.parseInt(parts[1]!, 10); + const minor = parts[2] !== undefined ? Number.parseInt(parts[2], 10) : 0; + const patch = parts[3] !== undefined ? Number.parseInt(parts[3], 10) : 0; + // A bare major (`11`) desugars in npm semver differently from an explicit + // floor (`11.0.0`): `>11` means `>=12`, but `>11.0.0` admits `11.0.1`. + const isBare = parts[2] === undefined && parts[3] === undefined; + // Whether the bound sits exactly on major `maj`'s first version. + const atMajorFloor = minor === 0 && patch === 0; + + switch (op) { + case '>=': + // `>=maj.*` admits major maj and up. + return runtimeMajor >= maj; + case '>': + // bare `>maj` → `>=maj+1` (excludes maj); `>maj.x.y` still admits maj. + return isBare ? runtimeMajor > maj : runtimeMajor >= maj; + case '<=': + // `<=maj.*` admits major maj and below. + return runtimeMajor <= maj; + case '<': + // `'; + const targetMajor = leadingMajor(declared.range.replace(/^[\^~<>=\s]+/, '')); + const migrateCommand = + targetMajor !== null + ? `objectstack migrate meta --from ${targetMajor}` + : `objectstack migrate meta`; + const message = + `package '${packageId}' targets protocol ${declared.range} ` + + `(${declared.source}) but this runtime is protocol ${runtimeVersion}. ` + + `This is a major-version break. Run: ${migrateCommand}`; + + return { + status: 'incompatible', + runtimeMajor, + runtimeVersion, + requiredRange: declared.range, + source: declared.source, + diagnostic: { + code: 'OS_PROTOCOL_INCOMPATIBLE', + packageId, + requiredRange: declared.range, + rangeSource: declared.source, + runtimeVersion, + runtimeMajor, + targetMajor, + migrateCommand, + message, + }, + }; +} + +/** Warn hook — overridable for tests; defaults to `console.warn`. */ +export type WarnFn = (message: string) => void; + +/** + * Enforce the handshake at a load/install boundary. + * + * - `ok` → returns silently. + * - `no-range` → warns once (grandfathering; `objectstack lint` nudges the + * package to declare a range, and scaffolds stamp one going forward). + * - `unparsed-range` → warns (parser-coverage gap; never a false rejection). + * - `incompatible` → throws {@link ProtocolIncompatibleError}. + */ +export function assertProtocolCompat( + manifest: ProtocolHandshakeManifest, + runtimeVersion: string = PROTOCOL_VERSION, + warn: WarnFn = (m) => console.warn(m), +): void { + const result = checkProtocolCompat(manifest, runtimeVersion); + const pkg = manifest.id ?? ''; + switch (result.status) { + case 'ok': + return; + case 'no-range': + warn( + `[protocol] package '${pkg}' declares no engines.protocol range; ` + + `loading under protocol ${runtimeVersion} without a compatibility check (ADR-0087).`, + ); + return; + case 'unparsed-range': + warn( + `[protocol] package '${pkg}' declares an unrecognized ${result.source} range ` + + `'${result.requiredRange}'; skipping the protocol handshake (ADR-0087).`, + ); + return; + case 'incompatible': + throw new ProtocolIncompatibleError(result.diagnostic); + } +} diff --git a/packages/metadata-protocol/src/protocol-handshake-install.test.ts b/packages/metadata-protocol/src/protocol-handshake-install.test.ts new file mode 100644 index 0000000000..a1b5073a5a --- /dev/null +++ b/packages/metadata-protocol/src/protocol-handshake-install.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0087 P0 — the protocol handshake at the install seam. `installPackage` +// refuses a package whose declared `engines.protocol` excludes this runtime's +// protocol major BEFORE writing it to the registry, with a structured +// diagnostic — instead of letting the mismatch surface later as a deep crash. + +import { describe, it, expect, vi } from 'vitest'; +import { ProtocolIncompatibleError } from '@objectstack/metadata-core'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './index.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([['package', { publish }]]); + const impl = new ObjectStackProtocolImplementation(engine as any, () => services); + return { impl, registryCalls, publish }; +} + +const OLD = `^${PROTOCOL_MAJOR - 1}`; // a major this runtime no longer accepts +const CURRENT = `^${PROTOCOL_MAJOR}`; + +describe('installPackage — protocol handshake (ADR-0087 P0)', () => { + it('rejects an incompatible package before it reaches the registry', async () => { + const { impl, registryCalls, publish } = makeImpl(); + await expect( + (impl as any).installPackage({ + manifest: { id: 'com.acme.crm', version: '1.0.0', engines: { protocol: OLD } }, + }), + ).rejects.toBeInstanceOf(ProtocolIncompatibleError); + // The refusal happens BEFORE any registry write or durable persist. + expect(registryCalls).toHaveLength(0); + expect(publish).not.toHaveBeenCalled(); + }); + + it('the thrown diagnostic is structured and names the migrate command', async () => { + const { impl } = makeImpl(); + try { + await (impl as any).installPackage({ + manifest: { id: 'com.acme.crm', version: '1.0.0', engines: { protocol: OLD } }, + }); + throw new Error('expected installPackage to throw'); + } catch (e) { + expect(e).toBeInstanceOf(ProtocolIncompatibleError); + const d = (e as ProtocolIncompatibleError).diagnostic; + expect(d.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(d.packageId).toBe('com.acme.crm'); + expect(d.migrateCommand).toBe(`objectstack migrate meta --from ${PROTOCOL_MAJOR - 1}`); + expect(d.runtimeMajor).toBe(PROTOCOL_MAJOR); + } + }); + + 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 } }, + }); + expect(registryCalls).toHaveLength(1); + expect(res.package.status).toBe('installed'); + }); + + it('grandfathers a package with no declared range (warns, does not reject)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { impl, registryCalls } = makeImpl(); + const res: any = await (impl as any).installPackage({ + manifest: { id: 'com.acme.legacy', version: '1.0.0' }, + }); + expect(registryCalls).toHaveLength(1); + expect(res.package.status).toBe('installed'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no engines.protocol range')); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 67c0b5adf8..b73fa44c44 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5,7 +5,7 @@ import { IDataEngine } from '@objectstack/core'; import { readEnvWithDeprecation } from '@objectstack/types'; import type { MetadataHostEngine } from './host-engine.js'; import { SysMetadataRepository, type SysMetadataEngine } from './sys-metadata-repository.js'; -import { ConflictError } from '@objectstack/metadata-core'; +import { ConflictError, assertProtocolCompat } from '@objectstack/metadata-core'; import type { BatchUpdateRequest, BatchUpdateResponse, @@ -5923,6 +5923,15 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { if (typeof manifest.version !== 'string' || !manifest.version) { manifest.version = '0.1.0'; } + + // ADR-0087 D1 — protocol handshake. Refuse a package whose declared + // `engines.protocol` range excludes this runtime's major BEFORE writing + // it to the registry, with a structured diagnostic naming the migrate + // command — instead of letting the mismatch surface later as a deep + // schema/renderer crash. Packages with no range are grandfathered (warn + // only); an unparsed range never causes a false rejection. + assertProtocolCompat(manifest); + const pkg = this.engine.registry.installPackage(manifest as any, request.settings); // Best-effort durable persistence to `sys_packages` (non-fatal by diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 03f092895a..eec7dcad7b 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1498,6 +1498,8 @@ "OpsFilePathSchema (const)", "OpsPluginStructure (type)", "OpsPluginStructureSchema (const)", + "PROTOCOL_MAJOR (const)", + "PROTOCOL_VERSION (const)", "PackageArtifact (type)", "PackageArtifactInput (type)", "PackageArtifactSchema (const)", diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index 18fc48cbe8..611e260009 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -25,6 +25,7 @@ export * from './plugin-security-advanced.zod'; export * from './plugin-structure.zod'; export * from './plugin-validator.zod'; export * from './plugin-versioning.zod'; +export * from './protocol-version'; export * from './plugin.zod'; export * from './service-registry.zod'; export * from './startup-orchestrator.zod'; diff --git a/packages/spec/src/kernel/protocol-version.test.ts b/packages/spec/src/kernel/protocol-version.test.ts new file mode 100644 index 0000000000..bdd301131d --- /dev/null +++ b/packages/spec/src/kernel/protocol-version.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from './protocol-version'; + +describe('PROTOCOL_VERSION', () => { + it('is a valid semver string', () => { + expect(PROTOCOL_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('exposes the parsed major', () => { + expect(PROTOCOL_MAJOR).toBe(Number.parseInt(PROTOCOL_VERSION.split('.')[0]!, 10)); + expect(Number.isInteger(PROTOCOL_MAJOR)).toBe(true); + }); + + it('stays in lockstep with the package major (no drift)', () => { + // The protocol major MUST equal the published @objectstack/spec major, so + // the handshake a package declares (`engines.protocol: '^N'`) matches the + // version it actually installed. If this fails, bump PROTOCOL_VERSION in + // the same change as the package version. + const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url)); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; + const pkgMajor = Number.parseInt(pkg.version.split('.')[0]!, 10); + expect(PROTOCOL_MAJOR).toBe(pkgMajor); + }); +}); diff --git a/packages/spec/src/kernel/protocol-version.ts b/packages/spec/src/kernel/protocol-version.ts new file mode 100644 index 0000000000..b5d0924ad8 --- /dev/null +++ b/packages/spec/src/kernel/protocol-version.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The metadata/runtime **protocol version** this build of `@objectstack/spec` + * implements (ADR-0087 D1, ADR-0025 §3.2/§3.10 #3). + * + * This is the single source of truth the loader and the package installer + * check a package's `engines.protocol` range against before loading its + * metadata. Only the **major** participates in the handshake: a breaking + * change to the authorable surface bumps the major (and only ever through the + * ADR-0059 freeze-contract fork), so a consumer pinned to `^N` keeps loading + * across every `N.x` release and is refused — with a diagnostic, not a crash — + * the moment the runtime crosses into `N+1`. + * + * Kept in lockstep with the package's own major; `protocol-version.test.ts` + * asserts it against `package.json` so the two cannot drift. + */ +export const PROTOCOL_VERSION = '12.0.0'; + +/** The protocol major as an integer — the value the handshake compares. */ +export const PROTOCOL_MAJOR: number = Number.parseInt(PROTOCOL_VERSION.split('.')[0]!, 10);