From 590c30a77a496a61cb2528b13d74c19db1e3bb8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:50:29 +0000 Subject: [PATCH 1/6] wip(metadata-protocol,spec): unmanaged import plan module, request/response schemas, two refusal codes Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/package-unmanaged-install.ts | 459 ++++++++++++++++++ .../spec/src/api/error-code-ledger.zod.ts | 14 + .../spec/src/api/package-lifecycle.zod.ts | 66 +++ 3 files changed, 539 insertions(+) create mode 100644 packages/metadata-protocol/src/package-unmanaged-install.ts diff --git a/packages/metadata-protocol/src/package-unmanaged-install.ts b/packages/metadata-protocol/src/package-unmanaged-install.ts new file mode 100644 index 00000000000..5eca10a0094 --- /dev/null +++ b/packages/metadata-protocol/src/package-unmanaged-install.ts @@ -0,0 +1,459 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] The `unmanaged` install mode's copy-once import — the PURE half: + * what one package body contains, what of it the import can write, and every + * refusal that can be judged from the body alone. + * + * A package installs one of two ways. MANAGED: the package is the authority, + * it upgrades, and nothing it ships may be edited. UNMANAGED (the spelling + * ruled on the D6 execution card, 2026-10-11): every metadata item is copied + * ONCE into the environment ledger, becomes this environment's own metadata, + * is fully editable, and never follows an upgrade. The orchestration — + * reading the store, minting the base, writing each item through + * `saveMetaItem`, deleting the base when a write fails — is + * `ObjectStackProtocolImplementation.importUnmanagedPackage` (`protocol.ts`). + * This module holds the parts that need no store, so each is testable alone. + * + * ## What the import writes, and the one rule behind every refusal + * + * The import writes METADATA ITEMS: the elements of every manifest collection + * (`PLURAL_TO_SINGULAR`, the one map the artifact loader and the authoring lint + * read to decide which collections exist) plus the assembled `viewItems` + * channel, each through the same save door a Studio edit takes. It does the + * part of a managed install the manifest service does, and nothing more. + * + * The rule (the stage-0 ACCEPT's Q5 and Q7 answers): a declared mode is + * enforced, there is no partial copy, and every refusal that can be judged is + * raised BEFORE the base exists. So a body carrying anything the copy cannot + * reproduce as environment rows is refused whole, naming each key: + * + * - a type the registry declares runtime-uncreatable (`allowRuntimeCreate: + * false`) — asked of the save door's own predicate, handed in by the caller, + * never a list kept here. ⛔ The `OS_METADATA_WRITABLE` hatch is NOT + * consulted: opening it for the import would be a second write path; + * - code, which has no metadata form at all; + * - extensions of ANOTHER package's items, which the environment ledger has no + * row for; + * - the artifact envelope (`packages`, a nested `manifest`), because one + * import copies one package. + * + * ## What is deliberately NOT this verb's — and is not dropped + * + * Two kinds of key are left to the caller, on purpose, because a managed + * install leaves them out of the manifest service too: + * + * - `data` (seed datasets) and `translations` (i18n bundles) are SIDE EFFECTS + * the install door applies after registering the package, in either mode — + * `marketplace-install-local-plugin.ts` step 5 runs the seeds and loads the + * bundles. Seeds are data, never metadata items (the artifact loader says + * so beside its collection map), so a copy of the metadata cannot carry + * them; the door that calls this verb decides their fate; + * - deployment configuration (`i18n`, `requires`, `datasourceMapping`, `api`, + * …) is read by the BOOT of the stack that declares it, never by an install + * of a package, in either mode. + * + * Every other key is refused as unrecognised: a key added to the stack later + * is refused here, loudly, until it is classified — never silently skipped. + */ + +import { ManifestSchema, manifestIdRefusal, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; +import { METADATA_ITEM_NAME_PATTERN, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { ASSEMBLED_VIEW_ITEMS_KEY, expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec/ui'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { resolveTenancyPosture } from '@objectstack/types'; +import { deriveViewContainerObject } from '@objectstack/metadata'; + +/** + * How the import treats one top-level key of a package body that is NOT a + * metadata collection. Collections are read off {@link UNMANAGED_IMPORT_COLLECTIONS}. + * + * - `identity` — describes the package; carried onto the new base's manifest. + * - `base` — read to mint the base and never carried as written: `id` is + * replaced by the target id, `namespace` is kept, `scope` is + * stripped because the base is a WRITABLE package. + * - `code` — refused: code, or a descriptor of a code-bearing artifact. + * - `uncopyable`— refused: package content the environment ledger has no row for. + * - `envelope` — refused: it describes an artifact, not one package. + * - `door` — not this verb's: a side effect the install door applies. + * - `deployment`— not this verb's: deployment configuration no install applies. + */ +export type UnmanagedImportKeyDisposition = + | 'identity' + | 'base' + | 'code' + | 'uncopyable' + | 'envelope' + | 'door' + | 'deployment'; + +/** + * Every non-collection key a flattened package body may carry, classified. + * The union of `ManifestSchema`'s live keys and the stack definition's + * non-collection keys; `package-unmanaged-install.test.ts` holds it total + * against both schemas, so a key either schema gains reddens a test instead of + * being refused at a door with no reason on record. + */ +export const UNMANAGED_IMPORT_KEY_DISPOSITIONS: Readonly> = { + // ── The package's identity ── + id: 'base', + namespace: 'base', + scope: 'base', + name: 'identity', + version: 'identity', + type: 'identity', + description: 'identity', + // An object with no `datasource` routes to its package's default, so a + // base that dropped this would move every such object to `default`. + defaultDatasource: 'identity', + dependencies: 'identity', + engines: 'identity', + engine: 'identity', + + // ── Code, and the descriptors of a code-bearing plugin artifact ── + functions: 'code', + onEnable: 'code', + plugins: 'code', + devPlugins: 'code', + runtimeModule: 'code', + main: 'code', + runtime: 'code', + packaging: 'code', + integrity: 'code', + + // ── Content the environment ledger has no row for ── + objectExtensions: 'uncopyable', + picklistExtensions: 'uncopyable', + navigationContributions: 'uncopyable', + contributes: 'uncopyable', + + // ── The artifact, not one package ── + packages: 'envelope', + manifest: 'envelope', + + // ── The install door's side effects, in either mode ── + data: 'door', + translations: 'door', + + // ── Deployment configuration: read by a stack's boot, by no install ── + i18n: 'deployment', + datasourceMapping: 'deployment', + api: 'deployment', + server: 'deployment', + appName: 'deployment', + email: 'deployment', + sms: 'deployment', + requires: 'deployment', + tiers: 'deployment', + devHint: 'deployment', + devLogins: 'deployment', +}; + +/** + * Every collection the import copies: manifest key → metadata type. The + * manifest-collection spelling plus the assembled `viewItems` channel, which a + * managed install registers as `view` items too. Order is the spelling map's, + * with `viewItems` last, so a view container is written before a standalone + * view of the same object. + */ +export const UNMANAGED_IMPORT_COLLECTIONS: ReadonlyArray = [ + ...Object.entries(PLURAL_TO_SINGULAR), + [ASSEMBLED_VIEW_ITEMS_KEY, 'view'], +]; + +/** One item the import writes, in write order. */ +export interface UnmanagedImportItem { + /** Canonical (singular) metadata type. */ + type: string; + /** The name it is saved under — a view container's is the object it binds. */ + name: string; + /** The manifest key it came from. */ + key: string; + /** The body as the package declared it. */ + body: Record; + /** The view names a container expands into (empty for every other item). */ + expands: readonly string[]; +} + +/** Everything the import needs from the body, judged. */ +export interface UnmanagedImportPlan { + sourcePackageId: string; + sourceVersion?: string; + /** The base's namespace — the source's, declared or derived from its id. */ + namespace: string; + targetPackageId: string; + /** The manifest the new base is installed with. */ + baseManifest: Record; + items: UnmanagedImportItem[]; +} + +/** One reason a body cannot be imported, naming the key that carries it. */ +export interface UnmanagedImportFinding { + key: string; + reason: string; +} + +/** An ADR-0112 error: a registered `code`, its `status`, optional `details`. */ +function refusal(code: string, status: number, message: string, details?: Record): Error { + return Object.assign(new Error(message), { code, status, ...(details ? { details } : {}) }); +} + +/** A malformed request: no `code`, so a boundary answers the status-derived `VALIDATION_ERROR`. */ +function invalidRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +/** + * The deployment's REQUESTED tenancy posture, or `null` when the configured + * value is unrecognised. Requested, never in force (the stage-0 ACCEPT's Q2): + * a degraded walled boot reports `single` in force and would admit a copy into + * a deployment its operator declared multi-tenant. Read per call, as + * `resolveTenancyPosture` reads `process.env` live by contract. + */ +export function requestedTenancyPosture(): TenancyPosture | null { + try { + return resolveTenancyPosture(); + } catch { + return null; + } +} + +/** + * The walled-posture refusal, or `null` on a `single` deployment. An + * unrecognised posture value is read as WALLED — the fail-closed direction + * ADR-0105 argues for on exactly this input, and the one the protocol's other + * posture gate takes. + */ +export function unmanagedInstallPostureRefusal( + sourcePackageId: string, + posture: TenancyPosture | null = requestedTenancyPosture(), +): Error | null { + if (posture !== null && !postureEnforcesWall(posture)) return null; + const named = posture === null + ? 'an unrecognised OS_TENANCY_POSTURE value, read as walled' + : `the '${posture}' tenancy posture`; + return refusal( + 'UNMANAGED_INSTALL_WALLED_POSTURE', + 409, + `Package '${sourcePackageId}' cannot be installed unmanaged on this deployment: it runs ${named}. ` + + 'An unmanaged install copies the package into ONE shared, editable environment ledger, which a ' + + 'deployment that walls organizations from each other cannot hold. Install the package managed ' + + 'instead, or install it unmanaged on a single-organization deployment.', + { posture: posture ?? 'unrecognised' }, + ); +} + +/** The uncopyable refusal, naming every finding. */ +export function unmanagedInstallUncopyable(sourcePackageId: string, findings: UnmanagedImportFinding[]): Error { + const shown = findings.slice(0, 5).map((f) => `\`${f.key}\` — ${f.reason}`).join('; '); + const more = findings.length > 5 ? ` (+${findings.length - 5} more)` : ''; + return refusal( + 'UNMANAGED_INSTALL_UNCOPYABLE', + 422, + `Package '${sourcePackageId}' cannot be installed unmanaged: ${shown}${more}. An unmanaged install ` + + 'copies every item into the environment ledger or copies nothing, so a package carrying what ' + + 'cannot be copied is refused whole. Install it managed instead.', + { findings }, + ); +} + +/** Read a string-valued key, or `undefined`. */ +function stringKey(body: Record, key: string): string | undefined { + const value = body[key]; + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** + * Judge a package body and plan its import. Throws the first refusal the body + * alone can answer; the store-backed refusals (collisions, an existing target, + * an owned namespace) are the caller's. + * + * @param isRuntimeCreatable The save door's own "may a new item of this type be + * created" predicate — passed in so this module never keeps a second answer. + * @param loadOrderOf The registry's `loadOrder` for a type, so an object is + * written before the views and apps that name it. + */ +export function planUnmanagedImport( + manifest: Record, + options: { + targetPackageId?: string; + targetName?: string; + isRuntimeCreatable: (type: string) => boolean; + loadOrderOf: (type: string) => number; + }, +): UnmanagedImportPlan { + // The source id, parsed through its declaration — the sentence is the + // declaration's, surfaced, as every package door surfaces it. + const rawId = manifest.id; + const declaredId = ManifestSchema.shape.id.safeParse(rawId); + if (!declaredId.success) { + const [issue] = declaredId.error.issues; + throw invalidRequest( + typeof rawId === 'string' && issue?.message ? issue.message : manifestIdRefusal('manifest.id', rawId), + ); + } + const sourcePackageId = declaredId.data; + + const collectionTypes = new Map(UNMANAGED_IMPORT_COLLECTIONS); + const findings: UnmanagedImportFinding[] = []; + const identity: Record = {}; + for (const [key, value] of Object.entries(manifest)) { + if (value === undefined) continue; + const type = collectionTypes.get(key); + if (type !== undefined) { + if (!options.isRuntimeCreatable(type) && Array.isArray(value) && value.length > 0) { + findings.push({ + key, + reason: `${value.length} '${type}' item(s); the metadata-type registry declares '${type}' ` + + 'runtime-uncreatable, so no environment row can hold one', + }); + } + continue; + } + const disposition = UNMANAGED_IMPORT_KEY_DISPOSITIONS[key]; + switch (disposition) { + case 'identity': + identity[key] = value; + break; + case 'code': + findings.push({ key, reason: 'code, which has no metadata form an environment row can hold' }); + break; + case 'uncopyable': + findings.push({ + key, + reason: 'it extends items another package owns, and the environment ledger has no row for that', + }); + break; + case 'envelope': + findings.push({ + key, + reason: key === 'packages' + ? 'a release artifact carrying several packages — import each package body on its own' + : 'a nested manifest — flatten the bundle (its manifest fields beside its collections) first', + }); + break; + case 'base': + case 'door': + case 'deployment': + break; + default: + findings.push({ key, reason: 'not a key a package body declares, so the import cannot place it' }); + } + } + if (findings.length > 0) throw unmanagedInstallUncopyable(sourcePackageId, findings); + + // The namespace is the SOURCE's (the stage-0 ACCEPT's Q3): every copied + // object name carries its prefix, and the base must own that prefix for + // the save door's namespace rule to admit them. + const namespace = stringKey(manifest, 'namespace') ?? deriveNamespaceFromPackageId(sourcePackageId) ?? undefined; + if (namespace === undefined) { + throw invalidRequest( + `Cannot determine the namespace of package '${sourcePackageId}': it declares none, and none derives ` + + 'from its id. Declare `namespace` on the package — every copied object name carries it.', + ); + } + + // The target: a NEW base whose id differs from the source (Q3). + const targetPackageId = options.targetPackageId ?? `${sourcePackageId}-copy`; + const declaredTarget = ManifestSchema.shape.id.safeParse(targetPackageId); + if (!declaredTarget.success) { + throw invalidRequest(manifestIdRefusal('targetPackageId', targetPackageId)); + } + if (targetPackageId === sourcePackageId) { + throw invalidRequest( + `\`targetPackageId\` '${targetPackageId}' is the source package's own id. An unmanaged install writes ` + + 'into a NEW base whose id differs from the source, so nothing is registered under the source id ' + + 'and a later managed install of it is refused by name. Omit `targetPackageId` for ' + + `'${sourcePackageId}-copy', or name another id.`, + ); + } + + const items = enumerateItems(manifest, options.loadOrderOf); + const sourceVersion = stringKey(manifest, 'version'); + const baseManifest: Record = { + ...identity, + id: targetPackageId, + name: options.targetName ?? stringKey(manifest, 'name') ?? sourcePackageId, + namespace, + }; + return { + sourcePackageId, + ...(sourceVersion !== undefined ? { sourceVersion } : {}), + namespace, + targetPackageId, + baseManifest, + items, + }; +} + +/** + * Every item of every collection, named the way the artifact loader names it + * (a `defineView` container takes the object it binds), validated, and sorted + * into write order. Refuses a collection that is not an array, an item that is + * not an object or has no name, a name the save door's grammar refuses, and two + * items claiming one name. + */ +function enumerateItems( + manifest: Record, + loadOrderOf: (type: string) => number, +): UnmanagedImportItem[] { + const items: Array = []; + const problems: string[] = []; + UNMANAGED_IMPORT_COLLECTIONS.forEach(([key, type], keyIndex) => { + const value = manifest[key]; + if (value === undefined) return; + if (!Array.isArray(value)) { + problems.push(`\`${key}\` is not an array of ${type} definitions`); + return; + } + value.forEach((body: unknown, index) => { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + problems.push(`\`${key}[${index}]\` is not a ${type} definition object`); + return; + } + const container = type === 'view' && isAggregatedViewContainer(body); + const named = (body as { name?: unknown }).name; + const name = container || (type === 'view' && !named) + ? deriveViewContainerObject(body) + : (typeof named === 'string' ? named : undefined); + if (typeof name !== 'string' || name === '') { + problems.push(`\`${key}[${index}]\` has no name`); + return; + } + if (!METADATA_ITEM_NAME_PATTERN.test(name)) { + problems.push(`\`${key}[${index}]\` is named ${JSON.stringify(name)}, which is not a legal metadata item name`); + return; + } + const expands = container ? expandViewContainer(name, body).map((v) => v.name) : []; + items.push({ + type, + name, + key, + body: body as Record, + expands, + rank: [loadOrderOf(type), keyIndex, index], + }); + }); + }); + const seen = new Map(); + for (const item of items) { + const slot = `${item.type}/${item.name}`; + const first = seen.get(slot); + if (first !== undefined) problems.push(`two items claim ${slot} (\`${first}\` and \`${item.key}\`)`); + else seen.set(slot, item.key); + } + if (problems.length > 0) { + const shown = problems.slice(0, 5).join('; '); + // `INVALID_REQUEST` / 400: the save door's own code for a name its + // grammar refuses, raised here for every item at once instead of for + // the first one after the base exists. + throw refusal( + 'INVALID_REQUEST', + 400, + `The package body cannot be imported as written: ${shown}` + + `${problems.length > 5 ? ` (+${problems.length - 5} more)` : ''}.`, + ); + } + items.sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.rank[2] - b.rank[2]); + return items.map(({ rank: _rank, ...item }) => item); +} diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 8d5ab43b2a8..0fe35c556fb 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -635,6 +635,20 @@ export const ERROR_CODE_LEDGER = { 'REGISTRY_TYPE_NOT_CANONICAL', // [#9111] a SchemaRegistry overlay entry was offered a non-canonical metadata `type` — the mint door asserts, the caller folds 'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793) 'STORED_TYPE_NOT_CANONICAL', // [#8908] a package draft is stored under a non-canonical metadata type (pre-#7894 second-namespace residue) — refused at the publish pre-flight, batch-atomic; [#9174] also refused on `revertCommit`'s restore limb, per-item on `failed[]`, NOT batch-atomic + // [ADR-0131 D6] The `unmanaged` install mode's two refusals, both stamped + // by `importUnmanagedPackage` (`protocol.ts`) before the new base exists, + // so nothing is written. Each gives a consumer a branch no existing code + // gives: the package installs MANAGED instead, and neither the + // `OS_METADATA_WRITABLE` hatch (`NOT_CREATABLE`'s remedy) nor a retry + // helps. No HTTP door on this tree yet; the thrown value is the boundary. + // - UNCOPYABLE, 422: the body carries what the copy cannot write as + // environment rows — a runtime-uncreatable type, code, an extension of + // another package's items, or an artifact envelope. + // - WALLED_POSTURE, 409: the deployment's REQUESTED tenancy posture + // (`group` / `isolated`, or an unrecognised value, read fail-closed) + // walls organizations, and one shared editable copy cannot sit behind it. + 'UNMANAGED_INSTALL_UNCOPYABLE', + 'UNMANAGED_INSTALL_WALLED_POSTURE', 'UNSUPPORTED_QUERY_PARAM', 'VALIDATION_FAILED', 'VERSION_NOT_FOUND', diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index b6d76264afd..8b788f4d1ea 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -17,6 +17,11 @@ * schema declares the `data` payload, envelope-free — the same convention as * `PublishPackageDraftsResponseSchema` and its ledger row. * + * One pair is the exception: `ImportUnmanagedPackageRequestSchema` / + * `ImportUnmanagedPackageResponseSchema` (ADR-0131 D6) declare a protocol verb + * no route serves yet, and the request half is parsed by its producer rather + * than transcribed — see their own docblocks. + * * `packages.publish`'s contract is NOT here: its producer * (`MetadataManager.publishPackage`) already has an exact published schema, * `PackagePublishResultSchema` in `@objectstack/spec/system` — re-exported @@ -216,6 +221,61 @@ export const DuplicatePackageResponseSchema = lazySchema(() => z.object({ })).describe('Every item the clone could not copy.'), })); +/** + * The request of `importUnmanagedPackage` — the copy-once import behind the + * `unmanaged` install mode (ADR-0131 D6): every metadata item of ONE package + * is written into a NEW writable base in the environment ledger, editable from + * then on, never upgraded. + * + * Unlike the transcriptions above, this declaration is ENFORCED: the producer + * (`@objectstack/metadata-protocol` `protocol.ts`) parses its request through + * it, so a key it does not declare is refused rather than ignored. No route + * serves the verb yet; the install door that reads an `installMode` calls it. + * The actor is stated by that door, never by a body, so it is not declared + * here. + */ +export const ImportUnmanagedPackageRequestSchema = lazySchema(() => z.object({ + manifest: z.record(z.string(), z.unknown()).describe( + 'The package to import: ONE package\'s assembled body, flattened — its manifest fields beside its ' + + 'metadata collections (`objects`, `views`, …), the shape the install door hands the manifest ' + + 'service. An artifact carrying `packages` is refused: import each package body on its own.', + ), + targetPackageId: z.string().optional().describe( + 'Id of the NEW writable base the items are written into, in reverse-domain form. It must differ ' + + 'from the source id and name no installed package. Defaults to `-copy`.', + ), + targetName: z.string().optional().describe( + 'Display name of the new base. Defaults to the source package\'s `name`.', + ), +}).strict()); + +/** + * The answer of `importUnmanagedPackage`. + * + * Transcribed from the producer's declared return. The import is + * all-or-nothing: every judgeable refusal is raised before the base exists, + * and a write that fails deletes the base, so a returned answer always means + * every item was copied. There is no `failed[]` and no `success` verdict to + * read — a failure is a thrown ADR-0112 error, never a partial answer. + */ +export const ImportUnmanagedPackageResponseSchema = lazySchema(() => z.object({ + sourcePackageId: z.string().describe('The id of the package the items were copied from.'), + sourceVersion: z.string().optional().describe( + 'The version the imported body declared, when it declared one.', + ), + targetPackageId: z.string().describe( + 'The new writable base that owns every copied item. Nothing is registered under the source id.', + ), + namespace: z.string().describe( + 'The base\'s namespace — the SOURCE package\'s, kept so every copied object name stays valid.', + ), + copiedCount: z.number().describe('How many items were copied.'), + copied: z.array(z.object({ + type: z.string().describe('Metadata type of the copied item.'), + name: z.string().describe('Name of the copied item.'), + })).describe('Every item that was copied, in the order it was written.'), +})); + export type DiscardPackageDraftsResponse = z.input; /** Post-parse shape of {@link DiscardPackageDraftsResponse} — defaults applied, transforms run (ADR-0122). */ export type DiscardPackageDraftsResponseParsed = z.infer; @@ -237,3 +297,9 @@ export type ReassignOrphanedMetadataResponseParsed = z.infer; /** Post-parse shape of {@link DuplicatePackageResponse} — defaults applied, transforms run (ADR-0122). */ export type DuplicatePackageResponseParsed = z.infer; +export type ImportUnmanagedPackageRequest = z.input; +/** Post-parse shape of {@link ImportUnmanagedPackageRequest} — defaults applied, transforms run (ADR-0122). */ +export type ImportUnmanagedPackageRequestParsed = z.infer; +export type ImportUnmanagedPackageResponse = z.input; +/** Post-parse shape of {@link ImportUnmanagedPackageResponse} — defaults applied, transforms run (ADR-0122). */ +export type ImportUnmanagedPackageResponseParsed = z.infer; From 3315adfd629d1df6a797e7c65375533be0a7fe7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:53:42 +0000 Subject: [PATCH 2/6] wip(metadata-protocol): importUnmanagedPackage verb and its write face Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/package-unmanaged-install.ts | 2 +- packages/metadata-protocol/src/protocol.ts | 356 +++++++++++++++++- 2 files changed, 353 insertions(+), 5 deletions(-) diff --git a/packages/metadata-protocol/src/package-unmanaged-install.ts b/packages/metadata-protocol/src/package-unmanaged-install.ts index 5eca10a0094..352e1b7afc9 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.ts @@ -62,7 +62,7 @@ import { METADATA_ITEM_NAME_PATTERN, PLURAL_TO_SINGULAR } from '@objectstack/spe import { ASSEMBLED_VIEW_ITEMS_KEY, expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec/ui'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; -import { deriveViewContainerObject } from '@objectstack/metadata'; +import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; /** * How the import treats one top-level key of a package body that is NOT a diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fcff022238c..b5bd99d35a8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -123,8 +123,15 @@ import type { UpdateManyDataRequest, DeleteManyDataRequest, InstallPackageRequest, - InstallPackageResponse + InstallPackageResponse, + ImportUnmanagedPackageRequest, + ImportUnmanagedPackageResponse, } from '@objectstack/spec/api'; +// [ADR-0131 D6] The `unmanaged` install mode: its request is PARSED through +// the published declaration, and the pure half of the import (what a package +// body holds, what is copied, what is refused) lives beside this file. +import { ImportUnmanagedPackageRequestSchema } from '@objectstack/spec/api'; +import { planUnmanagedImport, unmanagedInstallPostureRefusal } from './package-unmanaged-install.js'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api'; import type { ApiError, BatchOperationResult } from '@objectstack/spec/api'; import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; @@ -3418,8 +3425,9 @@ function specValidationFindings( // Byte-identical to the clause before commit d806081dd: the first three findings // as `: `, then a `(+N more)` tail. Read by // `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s - // `rows[].reason`, and the two out-of-package log faces — none of - // which carry `issues[]`. + // `rows[].reason`, `importUnmanagedPackage`'s rolled-back refusal + // (`'package-unmanaged-import'`), and the two out-of-package log + // faces — none of which carry `issues[]`. return issues.slice(0, 3) .map((i: { path: string; message: string }) => `${i.path || ''}: ${i.message}`) .join('; ') @@ -5026,8 +5034,21 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{ * the findings. It therefore stays OUT of {@link specValidationFindings}' * trimming case and keeps the full-prose default — the polarity that comment * defends, working as designed for a door added after it. + * + * ⚠️ [ADR-0131 D6] `'package-unmanaged-import'` is stated by + * {@link ObjectStackProtocolImplementation.importUnmanagedPackage}, the + * `unmanaged` install mode's copy-once import, for every item it writes. Its + * refusal is relayed by that verb's own thrown error, which carries the + * item's message and nothing structured beside it, so it too keeps the + * full-prose default on the 422. Its 409 clause is its own: the import accepts + * no `force`, and it writes only names no environment row held when it began. */ -type MetadataWriteFace = 'package-duplicate' | 'meta-envelope' | 'meta-dispatch' | 'external-import'; +type MetadataWriteFace = + | 'package-duplicate' + | 'meta-envelope' + | 'meta-dispatch' + | 'external-import' + | 'package-unmanaged-import'; function destructiveChangeRemedy( face: MetadataWriteFace | undefined, @@ -5072,6 +5093,19 @@ function destructiveChangeRemedy( return `this import cannot be forced: the external-table import route accepts no \`force\`. ` + `Import the table under a new \`name\`, or save the changed definition of '${name}' ` + `through \`PUT /api/v1/meta/object/${name}?force=true\`, which accepts the destructive change on purpose.`; + case 'package-unmanaged-import': + // [ADR-0131 D6] The `unmanaged` install mode's copy-once import. + // It refuses every name an environment row already holds before + // it writes anything, so reaching this gate means a row appeared + // under the name while the import ran. The import accepts no + // `force`; what the caller can do is reconcile the item that + // appeared, then import again. What became of the base is the + // verb's own sentence around this one, which it writes only once + // it has read the rollback's outcome — never claimed here. Same + // grammar as the faces above — name the door, deny the mechanism, + // then prescribe. + return `this import cannot be forced: the unmanaged install accepts no \`force\`. ` + + `Reconcile the environment item '${name}' that appeared while the import ran, then install again.`; default: return 're-submit with ?force=true to proceed.'; } @@ -24350,6 +24384,320 @@ export class ObjectStackProtocolImplementation implements }; } + /** + * [ADR-0131 D6] The `unmanaged` install mode's copy-once import: every + * metadata item of ONE package body is written into a NEW writable base in + * the environment ledger, through {@link saveMetaItem} — the same save + * door a Studio edit takes — so every copied item is environment metadata + * from its first byte: owned by the base, `organization_id` NULL, served + * `_provenance: 'org'`, editable by the next save, and followed by no + * upgrade. The managed mode is the package install; this verb is what that + * install's manifest service is to it, and no more (see + * `package-unmanaged-install.ts` for what is copied, what is refused, and + * what is left to the door). + * + * ## All or nothing (the stage-0 ACCEPT's Q7) + * + * Every refusal that can be judged is raised BEFORE the base exists, so a + * refused import writes nothing — no package record, no row: + * 1. an organization-scoped request (ADR-0131 D6, as every write verb); + * 2. a request its published declaration refuses + * (`ImportUnmanagedPackageRequestSchema`); + * 3. the deployment's REQUESTED tenancy posture walls organizations + * (`UNMANAGED_INSTALL_WALLED_POSTURE` / 409; Q2: requested, never in + * force, and an unrecognised value reads as walled); + * 4. the body carries what cannot be copied (`UNMANAGED_INSTALL_UNCOPYABLE` + * / 422; Q5), names it badly, or names no usable target (400); + * 5. the ADR-0087 D1 protocol handshake; + * 6. the target id names an installed package (`RESOURCE_CONFLICT` / 409; + * Q3: ⛔ never into an existing base) or the namespace is owned; + * 7. an item does not convert to today's protocol (`INVALID_METADATA` / + * 422), or a flow's conversion would clobber a live name + * (`FLOW_CONVERSION_CONFLICT` / 409); + * 8. a name collides with an environment row or an item a managed package + * ships (`RESOURCE_CONFLICT` / 409). + * + * Then the base is installed ({@link installPackage}, scope-less, the + * source namespace kept, an id that differs from the source) and every item + * is written in registry `loadOrder`. A write that fails deletes the base + * ({@link deletePackage}, which removes its rows and drops its tables), and + * the failure is thrown with the item's own `code` and `status`, naming the + * item and what the rollback did. There is no half-copied app. + * + * ⛔ No `OS_METADATA_WRITABLE` hatch: a runtime-uncreatable type is refused + * by the registry's own flag, never opened for this verb — a second write + * path is exactly what Q5 rules out. + * + * `actor` is stated by the door that calls this verb, never by a body; it + * is outside the published request for that reason. + */ + async importUnmanagedPackage( + request: ImportUnmanagedPackageRequest & { actor?: string }, + ): Promise { + const rawSourceId = (request as { manifest?: { id?: unknown } } | undefined)?.manifest?.id; + const sourceLabel = typeof rawSourceId === 'string' && rawSourceId !== '' ? rawSourceId : '(unnamed)'; + // 1. FIRST, as on every package write verb. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite(`Package '${sourceLabel}'`, request); + + // 2. The request, through its published declaration. The two keys that + // are never a body's — the door's `actor`, and the organization the + // refusal above has already judged — are read off before the parse. + const { actor, organizationId: _scopeJudgedAbove, ...body } = + (request ?? {}) as ImportUnmanagedPackageRequest & { actor?: string; organizationId?: unknown }; + const declared = ImportUnmanagedPackageRequestSchema.safeParse(body); + if (!declared.success) { + const issues = zodIssuesToMetadataIssues(declared.error.issues); + throw Object.assign( + new Error(`Invalid unmanaged install request: ${metadataIssueHeadline(issues)}.`), + { statusCode: 400, issues }, + ); + } + + // 3. The deployment walls organizations: nothing is read or written. + const walled = unmanagedInstallPostureRefusal(sourceLabel); + if (walled) throw walled; + + // 4. Everything the body alone can answer. + const plan = planUnmanagedImport(declared.data.manifest, { + ...(declared.data.targetPackageId !== undefined ? { targetPackageId: declared.data.targetPackageId } : {}), + ...(declared.data.targetName !== undefined ? { targetName: declared.data.targetName } : {}), + // The save door's own predicate — registry-only, the hatch unread. + isRuntimeCreatable: (type) => ObjectStackProtocolImplementation.isRuntimeCreateAllowed(type), + loadOrderOf: (type) => + DEFAULT_METADATA_TYPE_REGISTRY.find((entry) => entry.type === type)?.loadOrder ?? Number.MAX_SAFE_INTEGER, + }); + const { sourcePackageId, targetPackageId, namespace } = plan; + + // 5. The handshake `installPackage` asks too — asked here so a refusal + // precedes the base. + assertProtocolCompat(declared.data.manifest); + + // 6. A NEW base, under a namespace nobody owns. + const registry: any = (this.engine as any).registry; + if (registry?.getPackage?.(targetPackageId) || (this.engine as any).manifests?.has?.(targetPackageId)) { + throw Object.assign(new Error( + `Package '${targetPackageId}' already exists. An unmanaged install of '${sourcePackageId}' writes into a ` + + 'NEW base, never into an existing one. Name another `targetPackageId`, or uninstall ' + + `'${targetPackageId}' first.`, + ), { code: 'RESOURCE_CONFLICT', status: 409, details: { targetPackageId } }); + } + const owners: unknown = registry?.getNamespaceOwners?.(namespace); + if (Array.isArray(owners) && owners.length > 0) { + throw Object.assign(new Error( + `The namespace '${namespace}' of package '${sourcePackageId}' is already owned by ` + + `${owners.map((o) => `'${String(o)}'`).join(', ')}. The unmanaged copy keeps the source namespace, ` + + 'so it cannot sit beside a package that owns it — a managed install of this same package ' + + 'included. Uninstall that package first, or keep the managed install.', + ), { code: 'RESOURCE_CONFLICT', status: 409, details: { namespace, owners } }); + } + + // 7. Today's protocol, item by item — the conversion a managed install + // applies to an artifact, so an older package imports as it installs. + const canonicalizeFlow = plan.items.some((i) => i.type === 'flow') ? this.resolveFlowCanonicalizer() : undefined; + const prepared: Array<{ type: string; name: string; item: unknown }> = []; + const unconvertible: string[] = []; + const flowConflicts: string[] = []; + for (const planned of plan.items) { + try { + if (planned.type !== 'flow') { + prepared.push({ type: planned.type, name: planned.name, item: this.convertStoredItem(planned.type, planned.body) }); + } else if (!canonicalizeFlow) { + // No engine in this process: the body as declared, exactly as + // `duplicatePackage` copies it; the save door still judges it. + prepared.push({ type: planned.type, name: planned.name, item: planned.body }); + } else { + const result = canonicalizeFlow(planned.name, planned.body); + if (result.conflicts.length > 0) { + const first = result.conflicts[0]!; + flowConflicts.push(`flow '${planned.name}': '${first.token}' at ${first.path} — ${first.message}`); + } else { + prepared.push({ type: planned.type, name: planned.name, item: result.storable }); + } + } + } catch (e: any) { + unconvertible.push(`${planned.type} '${planned.name}': ${e?.message ?? String(e)}`); + } + } + if (unconvertible.length > 0) { + throw Object.assign(new Error( + `Package '${sourcePackageId}' cannot be installed unmanaged: ${unconvertible.length} item(s) do not convert ` + + `to this runtime's protocol — ${unconvertible.slice(0, 3).join('; ')}` + + `${unconvertible.length > 3 ? ` (+${unconvertible.length - 3} more)` : ''}. Rebuild the package against ` + + 'this protocol, then install again.', + ), { code: 'INVALID_METADATA', status: 422 }); + } + if (flowConflicts.length > 0) { + throw Object.assign(new Error( + `Package '${sourcePackageId}' cannot be installed unmanaged: converting its flows would overwrite live ` + + `names in this environment — ${flowConflicts.slice(0, 3).join('; ')}` + + `${flowConflicts.length > 3 ? ` (+${flowConflicts.length - 3} more)` : ''}.`, + ), { code: 'FLOW_CONVERSION_CONFLICT', status: 409 }); + } + + // 8. Every name the copy would take, against the environment and the + // managed packages — the save door would refuse or overlay them. + const collisions = await this.unmanagedImportCollisions(plan.items); + if (collisions.length > 0) { + throw Object.assign(new Error( + `Package '${sourcePackageId}' cannot be installed unmanaged: ${collisions.length} of its names are already ` + + `taken — ${collisions.slice(0, 3).map((c) => `${c.type} '${c.name}' (${c.heldBy})`).join('; ')}` + + `${collisions.length > 3 ? ` (+${collisions.length - 3} more)` : ''}. The copy keeps every name ` + + 'the package declares, so remove or rename the items that hold them, then install again.', + ), { code: 'RESOURCE_CONFLICT', status: 409, details: { collisions } }); + } + + // The base. Not best-effort: a refusal here is the import's, and + // `installPackage` leaves nothing behind when it refuses. + await this.installPackage({ manifest: plan.baseManifest } as InstallPackageRequest); + + const copied: Array<{ type: string; name: string }> = []; + for (const item of prepared) { + try { + await this.saveMetaItem({ + type: item.type, + name: item.name, + item: item.item, + mode: 'publish', + packageId: targetPackageId, + // Server-stated, as on every face: no body reaches this field. + writeFace: 'package-unmanaged-import', + ...(actor ? { actor } : {}), + }); + copied.push({ type: item.type, name: item.name }); + } catch (e: any) { + throw await this.rollBackUnmanagedImport(e, { + sourcePackageId, targetPackageId, failed: item, copiedCount: copied.length, actor, + }); + } + } + + return { + sourcePackageId, + ...(plan.sourceVersion !== undefined ? { sourceVersion: plan.sourceVersion } : {}), + targetPackageId, + namespace, + copiedCount: copied.length, + copied, + }; + } + + /** + * [ADR-0131 D6] The names an unmanaged import would take that something + * already holds: an environment row of any package and any state, or an + * item a managed package ships ({@link isArtifactBacked}). A view + * container's expansion names are asked too, since the save door files + * each of them. + * + * An unprovisioned `sys_metadata` holds no rows; any other read failure is + * thrown (503), because "no collision" invented from an outage would let + * the import write over rows it never saw. + */ + private async unmanagedImportCollisions( + items: ReadonlyArray<{ type: string; name: string; expands: readonly string[] }>, + ): Promise> { + const wanted = new Map(); + for (const item of items) { + wanted.set(`${item.type}/${item.name}`, { type: item.type, name: item.name }); + for (const view of item.expands) wanted.set(`view/${view}`, { type: 'view', name: view }); + } + const found = new Map(); + const names = [...new Set([...wanted.values()].map((slot) => slot.name))]; + try { + for (let at = 0; at < names.length; at += 200) { + // [#21911] The explicit system opt-in — see findServedOverlayRow. + const rows = (await this.engine.find('sys_metadata', { + where: { organization_id: null, name: { $in: names.slice(at, at + 200) } }, + context: { isSystem: true }, + })) as Array<{ type?: unknown; name?: unknown; package_id?: unknown }>; + for (const row of rows ?? []) { + const type = PLURAL_TO_SINGULAR[String(row.type)] ?? String(row.type); + const key = `${type}/${String(row.name)}`; + if (!wanted.has(key) || found.has(key)) continue; + found.set(key, { + type, + name: String(row.name), + heldBy: typeof row.package_id === 'string' && row.package_id !== '' + ? `an environment item of package '${row.package_id}'` + : 'an environment item', + }); + } + } + } catch (error) { + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + for (const [key, slot] of wanted) { + if (found.has(key) || !this.isArtifactBacked(slot.type, slot.name)) continue; + const owner = (this.lookupArtifactItem(slot.type, slot.name) as { _packageId?: unknown } | undefined)?._packageId; + found.set(key, { + ...slot, + heldBy: typeof owner === 'string' ? `shipped by the managed package '${owner}'` : 'shipped by a managed package', + }); + } + return [...found.values()]; + } + + /** + * [ADR-0131 D6] Undo a failed unmanaged import: uninstall the base it was + * writing into — its rows, its tables, its package record — and return the + * error the import throws. + * + * The thrown error keeps the failed item's own `code` and `status` (the + * closed vocabulary its refusal already declared), and its message says + * what the rollback did. A rollback that could not finish is also logged at + * `error`, once: the base and some of its rows are still there while the + * import has answered failure, and on a 5xx a boundary withholds the + * message that says so. + */ + private async rollBackUnmanagedImport( + cause: any, + context: { + sourcePackageId: string; + targetPackageId: string; + failed: { type: string; name: string }; + copiedCount: number; + actor?: string; + }, + ): Promise { + const { sourcePackageId, targetPackageId, failed, copiedCount, actor } = context; + let residue: string | undefined; + try { + const outcome = await this.deletePackage({ packageId: targetPackageId, ...(actor ? { actor } : {}) }); + if (outcome.failedCount > 0) { + residue = `${outcome.failedCount} of its items could not be removed`; + } + } catch (rollbackError) { + residue = `the uninstall failed: ${clientFacingFailureText(rollbackError, 'uninstall failed')}`; + } + if (residue !== undefined) { + console.error( + `[protocol.importUnmanagedPackage] the unmanaged install of '${sourcePackageId}' failed and its base ` + + `'${targetPackageId}' was NOT fully removed (${residue}): the environment still holds a partial copy ` + + `while the install answered failure. Uninstall the package '${targetPackageId}' to remove it.`, + ); + } + const rolledBack = residue === undefined + ? `the new base '${targetPackageId}' was removed, so nothing of the package remains` + : `the new base '${targetPackageId}' could NOT be fully removed (${residue}) — uninstall it to finish`; + return Object.assign( + new Error( + `The unmanaged install of '${sourcePackageId}' was rolled back: ${failed.type} '${failed.name}' could not ` + + `be written — ${clientFacingFailureText(cause, 'write failed')}. ${rolledBack}.`, + ), + { + ...(typeof cause?.code === 'string' ? { code: cause.code } : {}), + ...(typeof cause?.status === 'number' ? { status: cause.status } : {}), + ...(typeof cause?.statusCode === 'number' ? { statusCode: cause.statusCode } : {}), + ...(Array.isArray(cause?.issues) ? { issues: cause.issues } : {}), + details: { + failedItem: { type: failed.type, name: failed.name }, + copiedBeforeFailure: copiedCount, + rolledBack: residue === undefined, + }, + cause, + }, + ); + } + /** * ADR-0070 D5 — adopt orphaned (package-less) metadata into a base. The * pre-package-first stopgaps left runtime-authored items with From 3fac0f3b4ee07cb9cc6783b6d682f41d4b0b2ba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:11:43 +0000 Subject: [PATCH 3/6] test(metadata-protocol): pin the unmanaged import's stage-1 acceptance Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/package-unmanaged-install.test.ts | 534 ++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 packages/metadata-protocol/src/package-unmanaged-install.test.ts diff --git a/packages/metadata-protocol/src/package-unmanaged-install.test.ts b/packages/metadata-protocol/src/package-unmanaged-install.test.ts new file mode 100644 index 00000000000..1f128c3a70b --- /dev/null +++ b/packages/metadata-protocol/src/package-unmanaged-install.test.ts @@ -0,0 +1,534 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] The `unmanaged` install mode's copy-once import — the plan + * (`package-unmanaged-install.ts`) and the verb + * (`ObjectStackProtocolImplementation.importUnmanagedPackage`). + * + * The pins are the stage-0 plan's acceptance for this stage, each built here: + * + * - every item is copied with `package_id` = the new base and + * `organization_id` NULL, and nothing lands under the source id; + * - a walled REQUESTED posture is refused with code + status and ZERO writes + * (no base record, no row) — with a lit control on `single`; + * - a collision is refused before the base is created; + * - a mid-copy failure leaves no base and no row; + * - a follow-up save on a copied item succeeds: editability shown by a + * write, not by a flag; + * - a runtime-uncreatable type or code is refused before any write, and the + * `OS_METADATA_WRITABLE` hatch does not open it. + * + * Refusals are asserted as the envelope an HTTP boundary answers with + * (`resolveThrownHttpError`), never as a bare throw. + * + * The engine below is a stub: this package cannot import `@objectstack/objectql` + * (that package depends on this one). It keeps real rows, so the REAL save + * door, the REAL install primitive and the REAL uninstall run against it; its + * write verbs route through the producer's own dispatch predicates. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { ImportUnmanagedPackageResponseSchema } from '@objectstack/spec/api'; +import { DEFAULT_METADATA_TYPE_REGISTRY, ManifestSchema } from '@objectstack/spec/kernel'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { + UNMANAGED_IMPORT_COLLECTIONS, + UNMANAGED_IMPORT_KEY_DISPOSITIONS, + planUnmanagedImport, +} from './package-unmanaged-install.js'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +type Row = Record; + +/** Equality, `null` and `$in`; any other operator is refused, so a matcher that ignored one cannot prove anything. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [key, want] of Object.entries(where)) { + if (want === undefined) continue; + if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); + const have = row[key] ?? null; + if (want !== null && typeof want === 'object' && !Array.isArray(want)) { + const ops = Object.keys(want); + if (ops.length !== 1 || ops[0] !== '$in') throw new Error(`stub engine: unsupported operator ${ops.join(',')}`); + if (!(want as { $in: unknown[] }).$in.includes(have)) return false; + continue; + } + if (have !== want) return false; + } + return true; +} + +/** A stub engine that keeps rows, and a registry that keeps packages and namespaces. */ +function makeEngine(opts: { artifacts?: Array<{ type: string; name: string; packageId: string }>; owners?: Record } = {}) { + const tables = new Map(); + const table = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + let nextId = 0; + const writes: Array<{ op: string; table: string }> = []; + const packages = new Map(); + const namespaces = new Map>( + Object.entries(opts.owners ?? {}).map(([ns, ids]) => [ns, new Set(ids)]), + ); + const artifacts = opts.artifacts ?? []; + // What the registry was handed — the body every read then serves. + const registered: Array<{ type: string; item: any }> = []; + const objects: Array<{ item: any; packageId: string }> = []; + const ddl: string[] = []; + const engine: any = { + async syncObjectSchema(name: any) { ddl.push(`sync ${typeof name === 'string' ? name : name?.name}`); }, + async dropObjectSchema(name: any) { ddl.push(`drop ${typeof name === 'string' ? name : name?.name}`); }, + async find(t: string, q?: { where?: Record }) { + return table(t).filter((r) => matches(r, q?.where)); + }, + async findOne(t: string, q: { where: Record }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + writes.push({ op: 'insert', table: t }); + nextId += 1; + const row = { id: `r_${nextId}`, ...data }; + table(t).push(row); + return { ...row }; + }, + async update(t: string, data: Row, q?: Record) { + assertEngineUpdateDispatch(data, q); + writes.push({ op: 'update', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const hit = table(t).filter((r) => matches(r, where)); + for (const r of hit) Object.assign(r, data); + return { id: hit[0]?.id ?? null }; + }, + async delete(t: string, q?: Record) { + assertEngineDeleteDispatch(q); + writes.push({ op: 'delete', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const keep = table(t).filter((r) => !matches(r, where)); + const deleted = table(t).length - keep.length; + tables.set(t, keep); + return { deleted }; + }, + registry: { + registerItem: (type: string, item: any) => { registered.push({ type, item }); }, + // The OBJECT schema every data and metadata read serves, and the + // package it is registered under. + registerObject: (item: any, packageId: string) => { objects.push({ item, packageId }); }, + unregisterItem: () => {}, + listItems: () => [], + getItem: () => undefined, + getObject: () => undefined, + getArtifactItem: (type: string, name: string) => { + const hit = artifacts.find((a) => a.type === type && a.name === name); + return hit ? { name, _packageId: hit.packageId } : undefined; + }, + getPackage: (id: string) => packages.get(id), + installPackage: (manifest: any) => { + const pkg = { manifest, status: 'installed', enabled: true }; + packages.set(manifest.id, pkg); + if (manifest.namespace) { + if (!namespaces.has(manifest.namespace)) namespaces.set(manifest.namespace, new Set()); + namespaces.get(manifest.namespace)!.add(manifest.id); + } + return pkg; + }, + uninstallPackage: (id: string) => { + const pkg = packages.get(id); + packages.delete(id); + if (pkg?.manifest?.namespace) namespaces.get(pkg.manifest.namespace)?.delete(id); + }, + getNamespaceOwners: (ns: string) => [...(namespaces.get(ns) ?? [])], + }, + }; + return { engine, table, writes, packages, registered, objects, ddl }; +} + +function makeProtocol(opts: Parameters[0] = {}) { + const harness = makeEngine(opts); + const protocol = new ObjectStackProtocolImplementation(harness.engine, () => new Map()) as any; + return { ...harness, protocol }; +} + +/** A small, schema-valid package body — an object, a view of it, and an app. */ +function crmBody(extra: Record = {}): Record { + return { + id: 'com.example.crm', + namespace: 'crm', + version: '4.0.0', + type: 'app', + name: 'CRM', + scope: 'cloud', + objects: [{ + name: 'crm_lead', + label: 'Lead', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }], + views: [{ + name: 'crm_lead_open', + label: 'Open leads', + object: 'crm_lead', + viewKind: 'list', + columns: [{ field: 'title', label: 'Title' }], + }], + // Not this verb's: the door applies seeds and bundles, in either mode. + data: [{ object: 'crm_lead', records: [{ title: 'First' }] }], + translations: [], + i18n: { defaultLocale: 'en' }, + ...extra, + }; +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the call to be refused, but it resolved'); +} + +const registryCreatable = (type: string) => + DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type)?.allowRuntimeCreate ?? true; +const registryLoadOrder = (type: string) => + DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type)?.loadOrder ?? Number.MAX_SAFE_INTEGER; +const plan = (body: Record, extra: Record = {}) => + planUnmanagedImport(body, { isRuntimeCreatable: registryCreatable, loadOrderOf: registryLoadOrder, ...extra }); + +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_METADATA_WRITABLE'] as const; +const saved: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + resetEnvWritableMetadataTypes(); +}); + +describe('the key classification is total against both schemas a package body is read with', () => { + const collections = new Set(UNMANAGED_IMPORT_COLLECTIONS.map(([key]) => key)); + // A key retired with a tombstone (ADR-0049) is refused as unrecognised, + // never classified: its declaration says nothing reads it. + const tombstoned = (schema: any) => /^\[REMOVED\]/.test(String(schema?.description ?? '')); + + it('classifies every live ManifestSchema key', () => { + const missing = Object.entries(ManifestSchema.shape) + .filter(([key, schema]) => !collections.has(key) && !tombstoned(schema) && !(key in UNMANAGED_IMPORT_KEY_DISPOSITIONS)) + .map(([key]) => key); + expect(missing).toEqual([]); + }); + + it('classifies every stack-definition key', () => { + const shape = (ObjectStackDefinitionSchema as any).shape as Record; + const missing = Object.keys(shape).filter((key) => !collections.has(key) && !(key in UNMANAGED_IMPORT_KEY_DISPOSITIONS)); + expect(missing).toEqual([]); + }); + + it('classifies nothing neither schema declares — a stale row is a lie about a key', () => { + const declared = new Set([...Object.keys(ManifestSchema.shape), ...Object.keys((ObjectStackDefinitionSchema as any).shape)]); + expect(Object.keys(UNMANAGED_IMPORT_KEY_DISPOSITIONS).filter((key) => !declared.has(key))).toEqual([]); + }); +}); + +describe('planUnmanagedImport — what the body alone answers', () => { + it('plans a NEW base: `-copy`, the SOURCE namespace kept, no scope, the identity carried', () => { + const p = plan(crmBody()); + expect(p.sourcePackageId).toBe('com.example.crm'); + expect(p.sourceVersion).toBe('4.0.0'); + expect(p.targetPackageId).toBe('com.example.crm-copy'); + expect(p.namespace).toBe('crm'); + expect(p.baseManifest).toEqual({ + id: 'com.example.crm-copy', + name: 'CRM', + namespace: 'crm', + version: '4.0.0', + type: 'app', + }); + }); + + it('derives the namespace from the source id when the body declares none', () => { + const { namespace: _ns, ...body } = crmBody(); + expect(plan(body).namespace).toBe('crm'); + }); + + it('writes in registry loadOrder: the object before the view, the view before the app', () => { + const p = plan(crmBody({ apps: [{ name: 'crm', label: 'CRM' }] })); + expect(p.items.map((i) => `${i.type}/${i.name}`)).toEqual(['object/crm_lead', 'view/crm_lead_open', 'app/crm']); + }); + + it('names a view container by the object it binds, and lists the names it expands into', () => { + const p = plan(crmBody({ + views: [{ object: 'crm_lead', list: { label: 'All', columns: [{ field: 'title' }] } }], + })); + const container = p.items.find((i) => i.type === 'view')!; + expect(container.name).toBe('crm_lead'); + expect(container.expands.length).toBeGreaterThan(0); + }); + + it('leaves the door\'s keys and deployment configuration alone — neither copied nor refused', () => { + const p = plan(crmBody({ requires: ['ui'], datasourceMapping: [] })); + expect(p.items.map((i) => i.key)).not.toContain('data'); + expect(p.baseManifest).not.toHaveProperty('data'); + expect(p.baseManifest).not.toHaveProperty('i18n'); + }); + + const uncreatable = UNMANAGED_IMPORT_COLLECTIONS.filter(([, type]) => !registryCreatable(type)); + it('has runtime-uncreatable collections to refuse — the premise, read off the registry', () => { + expect(uncreatable.map(([key]) => key)).toEqual(expect.arrayContaining(['jobs', 'agents'])); + }); + + for (const [key, type] of uncreatable) { + it(`refuses \`${key}\` — '${type}' is runtime-uncreatable — with UNMANAGED_INSTALL_UNCOPYABLE / 422`, () => { + let err: any; + try { plan(crmBody({ [key]: [{ name: 'crm_probe' }] })); } catch (e) { err = e; } + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(422); + expect(envelope.code).toBe('UNMANAGED_INSTALL_UNCOPYABLE'); + expect(err.details.findings.map((f: any) => f.key)).toEqual([key]); + }); + } + + for (const key of ['functions', 'onEnable', 'plugins', 'main', 'objectExtensions', 'navigationContributions', 'packages', 'not_a_package_key']) { + it(`refuses a body carrying \`${key}\` with UNMANAGED_INSTALL_UNCOPYABLE / 422, naming the key`, () => { + let err: any; + try { plan(crmBody({ [key]: key === 'onEnable' ? 'hook' : [{}] })); } catch (e) { err = e; } + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(422); + expect(envelope.code).toBe('UNMANAGED_INSTALL_UNCOPYABLE'); + expect(err.details.findings.map((f: any) => f.key)).toEqual([key]); + expect(err.message).toContain(`\`${key}\``); + }); + } + + it('refuses a target equal to the source, and one the id declaration refuses — 400, no code minted', () => { + for (const targetPackageId of ['com.example.crm', 'not-a-reverse-domain-id']) { + let err: any; + try { plan(crmBody(), { targetPackageId }); } catch (e) { err = e; } + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(400); + expect(envelope.code).toBe('VALIDATION_ERROR'); + expect(err.message).toContain('targetPackageId'); + } + }); + + it('refuses two items claiming one name, and a name the save door\'s grammar refuses — INVALID_REQUEST / 400', () => { + for (const objects of [ + [{ name: 'crm_lead', label: 'A' }, { name: 'crm_lead', label: 'B' }], + [{ name: 'CRM Lead', label: 'A' }], + ]) { + let err: any; + try { plan(crmBody({ objects })); } catch (e) { err = e; } + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(400); + expect(envelope.code).toBe('INVALID_REQUEST'); + } + }); +}); + +describe('importUnmanagedPackage — the verb', () => { + it('copies every item into a NEW base: package_id = the base, organization_id NULL, nothing under the source id', async () => { + const { protocol, table, packages } = makeProtocol(); + const result = await protocol.importUnmanagedPackage({ manifest: crmBody() }); + + expect(ImportUnmanagedPackageResponseSchema.parse(result)).toEqual(result); + expect(result).toEqual({ + sourcePackageId: 'com.example.crm', + sourceVersion: '4.0.0', + targetPackageId: 'com.example.crm-copy', + namespace: 'crm', + copiedCount: 2, + copied: [{ type: 'object', name: 'crm_lead' }, { type: 'view', name: 'crm_lead_open' }], + }); + const rows = table('sys_metadata').map((r) => `${r.type}/${r.name}@${r.package_id}|org=${r.organization_id}|${r.state}`); + expect(rows.sort()).toEqual([ + 'object/crm_lead@com.example.crm-copy|org=null|active', + 'view/crm_lead_open@com.example.crm-copy|org=null|active', + ]); + // The base: scope-less (writable), the source namespace, a new id. + expect(packages.get('com.example.crm-copy')?.manifest).toMatchObject({ id: 'com.example.crm-copy', namespace: 'crm' }); + expect(packages.get('com.example.crm-copy')?.manifest).not.toHaveProperty('scope'); + expect(packages.has('com.example.crm')).toBe(false); + }); + + it('creates the copied object\'s table through the save door', async () => { + const { protocol, ddl } = makeProtocol(); + await protocol.importUnmanagedPackage({ manifest: crmBody() }); + expect(ddl).toContain('sync crm_lead'); + }); + + it('hands the registry every copied item stamped `_provenance: \'org\'` — environment metadata, not package content', async () => { + const { protocol, registered, objects } = makeProtocol(); + await protocol.importUnmanagedPackage({ manifest: crmBody() }); + // A view is served from its registry item… + const view = registered.filter((r) => r.type === 'view' && r.item?.name === 'crm_lead_open'); + expect(view.length).toBeGreaterThan(0); + for (const r of view) expect(r.item._provenance).toBe('org'); + // …an object from its registered schema, under the BASE, never the source. + const object = objects.filter((o) => o.item?.name === 'crm_lead'); + expect(object.length).toBeGreaterThan(0); + for (const o of object) { + expect(o.item._provenance).toBe('org'); + expect(o.packageId).toBe('com.example.crm-copy'); + } + }); + + it('lets the next save edit a copied item — editable shown by a write, not by a flag', async () => { + const { protocol, table } = makeProtocol(); + await protocol.importUnmanagedPackage({ manifest: crmBody() }); + + // Editable, shown by a write. + const edited = await protocol.saveMetaItem({ + type: 'view', + name: 'crm_lead_open', + packageId: 'com.example.crm-copy', + item: { + name: 'crm_lead_open', + label: 'Open leads (edited)', + object: 'crm_lead', + viewKind: 'list', + columns: [{ field: 'title', label: 'Title' }], + }, + }); + expect(edited.success).toBe(true); + const row = table('sys_metadata').find((r) => r.type === 'view' && r.name === 'crm_lead_open' && r.state === 'active'); + expect(JSON.parse(row!.metadata).label).toBe('Open leads (edited)'); + + // …and the object, the type a MANAGED package seals against every edit. + const object = await protocol.saveMetaItem({ + type: 'object', + name: 'crm_lead', + packageId: 'com.example.crm-copy', + item: { + name: 'crm_lead', + label: 'Lead', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' }, source: { type: 'text', label: 'Source' } }, + }, + }); + expect(object.success).toBe(true); + const objectRow = table('sys_metadata').find((r) => r.type === 'object' && r.name === 'crm_lead' && r.state === 'active'); + expect(Object.keys(JSON.parse(objectRow!.metadata).fields)).toEqual(['title', 'source']); + }); + + for (const posture of ['isolated', 'group', 'nonsense']) { + it(`refuses on a requested '${posture}' posture with UNMANAGED_INSTALL_WALLED_POSTURE / 409 and ZERO writes`, async () => { + process.env.OS_TENANCY_POSTURE = posture; + const { protocol, writes, packages } = makeProtocol(); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: crmBody() })); + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(409); + expect(envelope.code).toBe('UNMANAGED_INSTALL_WALLED_POSTURE'); + expect(err.details.posture).toBe(posture === 'nonsense' ? 'unrecognised' : posture); + expect(writes).toEqual([]); + expect(packages.size).toBe(0); + }); + } + + it('the lit control: the same body under a requested `single` posture imports', async () => { + process.env.OS_TENANCY_POSTURE = 'single'; + const { protocol } = makeProtocol(); + const result = await protocol.importUnmanagedPackage({ manifest: crmBody() }); + expect(result.copiedCount).toBe(2); + }); + + it('refuses a runtime-uncreatable type before any write — and the OS_METADATA_WRITABLE hatch does not open it', async () => { + process.env.OS_METADATA_WRITABLE = 'job'; + resetEnvWritableMetadataTypes(); + const { protocol, writes, packages } = makeProtocol(); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ + manifest: crmBody({ jobs: [{ name: 'crm_sweep', label: 'Sweep', schedule: { type: 'cron', expression: '0 0 * * *' } }] }), + })); + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(422); + expect(envelope.code).toBe('UNMANAGED_INSTALL_UNCOPYABLE'); + expect(writes).toEqual([]); + expect(packages.size).toBe(0); + }); + + it('refuses a collision with an environment row before the base is created', async () => { + const { protocol, table, packages } = makeProtocol(); + table('sys_metadata').push({ + id: 'existing', type: 'object', name: 'crm_lead', organization_id: null, + package_id: 'com.example.studio', state: 'active', metadata: '{}', + }); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: crmBody() })); + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(409); + expect(envelope.code).toBe('RESOURCE_CONFLICT'); + expect(err.details.collisions).toEqual([ + { type: 'object', name: 'crm_lead', heldBy: "an environment item of package 'com.example.studio'" }, + ]); + expect(packages.size).toBe(0); + expect(table('sys_metadata')).toHaveLength(1); + }); + + it('refuses a name a managed package ships before the base is created', async () => { + const { protocol, packages } = makeProtocol({ + artifacts: [{ type: 'view', name: 'crm_lead_open', packageId: 'com.example.other' }], + }); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: crmBody() })); + expect(resolveThrownHttpError(err).code).toBe('RESOURCE_CONFLICT'); + expect(err.details.collisions).toEqual([ + { type: 'view', name: 'crm_lead_open', heldBy: "shipped by the managed package 'com.example.other'" }, + ]); + expect(packages.size).toBe(0); + }); + + it('refuses an owned namespace — a managed install of the same package included — before the base is created', async () => { + const { protocol, packages, writes } = makeProtocol({ owners: { crm: ['com.example.crm'] } }); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: crmBody() })); + expect(resolveThrownHttpError(err).code).toBe('RESOURCE_CONFLICT'); + expect(err.details).toEqual({ namespace: 'crm', owners: ['com.example.crm'] }); + expect(packages.size).toBe(0); + expect(writes).toEqual([]); + }); + + it('refuses a target id that names an installed package — never into an existing base', async () => { + const { protocol, engine, writes } = makeProtocol(); + engine.registry.installPackage({ id: 'com.example.mine', name: 'Mine' }); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ + manifest: crmBody(), targetPackageId: 'com.example.mine', + })); + expect(resolveThrownHttpError(err).code).toBe('RESOURCE_CONFLICT'); + expect(writes).toEqual([]); + }); + + it('refuses a request key its published declaration does not declare — 400, nothing written', async () => { + const { protocol, writes } = makeProtocol(); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: crmBody(), installMode: 'unmanaged' })); + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(400); + expect(envelope.code).toBe('VALIDATION_ERROR'); + expect(writes).toEqual([]); + }); + + it('a write that fails mid-copy deletes the base: no package record, no row, the item\'s own refusal thrown', async () => { + const { protocol, table, packages, ddl } = makeProtocol(); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ + // The second item is a view the save door refuses: it names no columns. + manifest: crmBody({ views: [{ name: 'crm_lead_bad', label: 'Bad', object: 'crm_lead', viewKind: 'list' }] }), + })); + // The save door's own refusal, code and status kept: a spec-invalid body. + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(422); + expect(envelope.code).toBe('INVALID_METADATA'); + expect(err.message).toContain("view 'crm_lead_bad' could not be written"); + expect(err.message).toContain("the new base 'com.example.crm-copy' was removed"); + expect(err.details).toEqual({ + failedItem: { type: 'view', name: 'crm_lead_bad' }, + copiedBeforeFailure: 1, + rolledBack: true, + }); + expect(packages.size).toBe(0); + expect(table('sys_metadata').filter((r) => r.package_id === 'com.example.crm-copy')).toEqual([]); + // The object the copy created had its table made, then dropped by the rollback. + expect(ddl).toEqual(expect.arrayContaining(['sync crm_lead', 'drop crm_lead'])); + }); +}); From 4e11f876e0339f9d028962b1ba039b68411df60e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:23:09 +0000 Subject: [PATCH 4/6] chore(spec): regenerate shards for the unmanaged import schemas; changeset Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/15213-unmanaged-import-verb.md | 18 ++++++++ content/docs/references/api/contract.mdx | 4 +- .../docs/references/api/error-code-ledger.mdx | 2 + .../docs/references/api/package-lifecycle.mdx | 45 ++++++++++++++++++- content/docs/references/index.mdx | 10 ++--- .../api.md | 2 +- packages/spec/api-surface/api.json | 6 +++ packages/spec/authorable-surface/api.json | 9 ++++ packages/spec/declaration-map/api.json | 4 ++ packages/spec/export-origins/api.json | 6 +++ packages/spec/json-schema.manifest/api.json | 2 + 11 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 .changeset/15213-unmanaged-import-verb.md diff --git a/.changeset/15213-unmanaged-import-verb.md b/.changeset/15213-unmanaged-import-verb.md new file mode 100644 index 00000000000..af966c215bc --- /dev/null +++ b/.changeset/15213-unmanaged-import-verb.md @@ -0,0 +1,18 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': minor +--- + +`importUnmanagedPackage` copies one package into the environment ledger, editable from then on — the import behind the `unmanaged` install mode (ADR-0131 D6) + +Clause-②: yes (widening) + +A package installs one of two ways. Managed: the package is the authority, it upgrades, and nothing it ships may be edited. Unmanaged: every metadata item is copied once into this environment, is fully editable, and never follows an upgrade. This release adds the import; no door calls it yet, and no manifest key declares the mode yet. + +- **The verb** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })` writes every metadata item of ONE package body into a NEW writable base through the metadata save door: each row is owned by the base, carries no organization, is served as environment-authored (`_provenance: 'org'`), and the next save edits it. The base's id differs from the source (`targetPackageId`, defaulting to `-copy`), it keeps the source namespace so every object name stays valid, and nothing is registered under the source id. The answer names the source, the base, its namespace and every item copied, in write order. +- **All or nothing.** Every refusal that can be judged is raised before the base exists, so a refused import writes nothing: a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value); a body carrying what cannot be copied; a malformed name or an unusable target id; a protocol-incompatible package; a target id that names an installed package, or a namespace another package owns (a managed install of the same package included); an item that does not convert to this protocol; a name an environment row or a managed package already holds. A write that fails afterwards deletes the base — its rows and its tables — and the failure is thrown with the item's own code and status, naming the item and what the rollback did. +- **What is copied, and what is refused.** The items of every manifest collection and the assembled `viewItems` are copied. A body carrying a runtime-uncreatable type (`jobs`, `agents`, `apis`, `capabilities`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), extensions of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`) or an artifact envelope (`packages`) is refused whole; `OS_METADATA_WRITABLE` does not open a type for it. Seed `data` and `translations` are left to the install door, which applies them after the copy as it does for a managed install; deployment configuration (`i18n`, `requires`, `datasourceMapping`, …) is not part of a package install in either mode. +- **Two new error codes** (`@objectstack/spec`, registered to `@objectstack/metadata-protocol`). `UNMANAGED_INSTALL_WALLED_POSTURE` (409) and `UNMANAGED_INSTALL_UNCOPYABLE` (422). Both mean the package can be installed managed instead. A collision, an existing target or an owned namespace answers the standard `RESOURCE_CONFLICT` (409). +- **Two new schemas** (`@objectstack/spec/api`). `ImportUnmanagedPackageRequestSchema` is parsed by the verb, so a request key it does not declare is refused with `400`. `ImportUnmanagedPackageResponseSchema` declares the answer. + +Nothing existing changes: no schema, door or code is removed or narrowed. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 66bcac5cb1d..3264f781e3f 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +323 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -353,6 +353,8 @@ const result = ApiErrorSchema.parse(data); * `UNIQUE_VIOLATION` * `UNKNOWN_KEY` * `UNKNOWN_NAMESPACE` +* `UNMANAGED_INSTALL_UNCOPYABLE` +* `UNMANAGED_INSTALL_WALLED_POSTURE` * `UNSUPPORTED` * `UNSUPPORTED_QUERY_PARAM` * `UNSUPPORTED_TRANSFORM` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index b0c50e4fea2..3fd3df9ce25 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -520,6 +520,8 @@ const result = ErrorCode.parse(data); * `UNIQUE_VIOLATION` * `UNKNOWN_KEY` * `UNKNOWN_NAMESPACE` +* `UNMANAGED_INSTALL_UNCOPYABLE` +* `UNMANAGED_INSTALL_WALLED_POSTURE` * `UNSUPPORTED` * `UNSUPPORTED_QUERY_PARAM` * `UNSUPPORTED_TRANSFORM` diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index 2a1059da128..7c6399805d3 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -22,6 +22,11 @@ through the `{ success, data }` envelope (`http-dispatcher.ts`), so each schema declares the `data` payload, envelope-free — the same convention as `PublishPackageDraftsResponseSchema` and its ledger row. +One pair is the exception: `ImportUnmanagedPackageRequestSchema` / +`ImportUnmanagedPackageResponseSchema` (ADR-0131 D6) declare a protocol verb +no route serves yet, and the request half is parsed by its producer rather +than transcribed — see their own docblocks. + `packages.publish`'s contract is NOT here: its producer (`MetadataManager.publishPackage`) already has an exact published schema, `PackagePublishResultSchema` in `@objectstack/spec/system` — re-exported @@ -43,8 +48,8 @@ authored after that retirement per the ruling's sequencing (3A). ## TypeScript Usage ```typescript -import { DiscardPackageDraftsResponseSchema, DuplicatePackageResponseSchema, ListPackageCommitsResponseSchema, PackageExportManifestSchema, PackagePublishResultSchema, ReassignOrphanedMetadataResponseSchema, RevertPackageCommitResponseSchema, RollbackToPackageCommitResponseSchema } from '@objectstack/spec/api'; -import type { DiscardPackageDraftsResponse, DuplicatePackageResponse, ListPackageCommitsResponse, PackageExportManifest, PackagePublishResult, ReassignOrphanedMetadataResponse, RevertPackageCommitResponse, RollbackToPackageCommitResponse } from '@objectstack/spec/api'; +import { DiscardPackageDraftsResponseSchema, DuplicatePackageResponseSchema, ImportUnmanagedPackageRequestSchema, ImportUnmanagedPackageResponseSchema, ListPackageCommitsResponseSchema, PackageExportManifestSchema, PackagePublishResultSchema, ReassignOrphanedMetadataResponseSchema, RevertPackageCommitResponseSchema, RollbackToPackageCommitResponseSchema } from '@objectstack/spec/api'; +import type { DiscardPackageDraftsResponse, DuplicatePackageResponse, ImportUnmanagedPackageRequest, ImportUnmanagedPackageResponse, ListPackageCommitsResponse, PackageExportManifest, PackagePublishResult, ReassignOrphanedMetadataResponse, RevertPackageCommitResponse, RollbackToPackageCommitResponse } from '@objectstack/spec/api'; // Validate data const result = DiscardPackageDraftsResponseSchema.parse(data); @@ -112,6 +117,42 @@ const result = DiscardPackageDraftsResponseSchema.parse(data); | **error** | `string` | ✅ | Why copying it failed. | +--- + +## ImportUnmanagedPackageRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `Record` | ✅ | The package to import: ONE package's assembled body, flattened — its manifest fields beside its metadata collections (`objects`, `views`, …), the shape the install door hands the manifest service. An artifact carrying `packages` is refused: import each package body on its own. | +| **targetPackageId** | `string` | optional | Id of the NEW writable base the items are written into, in reverse-domain form. It must differ from the source id and name no installed package. Defaults to `-copy`. | +| **targetName** | `string` | optional | Display name of the new base. Defaults to the source package's `name`. | + + +--- + +## ImportUnmanagedPackageResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **sourcePackageId** | `string` | ✅ | The id of the package the items were copied from. | +| **sourceVersion** | `string` | optional | The version the imported body declared, when it declared one. | +| **targetPackageId** | `string` | ✅ | The new writable base that owns every copied item. Nothing is registered under the source id. | +| **namespace** | `string` | ✅ | The base's namespace — the SOURCE package's, kept so every copied object name stays valid. | +| **copiedCount** | `number` | ✅ | How many items were copied. | +| **copied** | `{ type: string; name: string }[]` | ✅ | Every item that was copied, in the order it was written. | + +### Nested Shape: `ImportUnmanagedPackageResponse.copied[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Metadata type of the copied item. | +| **name** | `string` | ✅ | Name of the copied item. | + + --- ## ListPackageCommitsResponse diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index bc2c5bfb412..7e319acddf7 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1520 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 434 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 35 | 278 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1520** | 14 protocol modules | +| **Total** | **196** | **1522** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 432 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 434 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -89,7 +89,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` | | [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `ListInstalledPackagesRequest`, `PackageApiErrorCode`, `PackageInstallBody`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | -| [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | +| [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | | [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index e80aa9ab0da..104664c32ed 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 434 | +| `api/` | 437 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 4ffeb5e6fc4..acaa363c68d 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -533,6 +533,12 @@ "ImportResponseSchema (const)", "ImportRowResult (type)", "ImportRowResultSchema (const)", + "ImportUnmanagedPackageRequest (type)", + "ImportUnmanagedPackageRequestParsed (type)", + "ImportUnmanagedPackageRequestSchema (const)", + "ImportUnmanagedPackageResponse (type)", + "ImportUnmanagedPackageResponseParsed (type)", + "ImportUnmanagedPackageResponseSchema (const)", "ImportValidationConfig (type)", "ImportValidationConfigParsed (type)", "ImportValidationConfigSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index eb4bb9c9680..c7b74d42ae7 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -947,6 +947,15 @@ "api/ImportRowResult:ok", "api/ImportRowResult:row", "api/ImportRowResult:warnings", + "api/ImportUnmanagedPackageRequest:manifest", + "api/ImportUnmanagedPackageRequest:targetName", + "api/ImportUnmanagedPackageRequest:targetPackageId", + "api/ImportUnmanagedPackageResponse:copied", + "api/ImportUnmanagedPackageResponse:copiedCount", + "api/ImportUnmanagedPackageResponse:namespace", + "api/ImportUnmanagedPackageResponse:sourcePackageId", + "api/ImportUnmanagedPackageResponse:sourceVersion", + "api/ImportUnmanagedPackageResponse:targetPackageId", "api/ImportValidationConfig:dateFormat", "api/ImportValidationConfig:deduplication", "api/ImportValidationConfig:maxErrors", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 4478da5b68d..068260d6f13 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -397,6 +397,10 @@ "ImportResponseSchema": "api/ImportResponse", "ImportRowResult": "api/ImportRowResult", "ImportRowResultSchema": "api/ImportRowResult", + "ImportUnmanagedPackageRequest": "api/ImportUnmanagedPackageRequest", + "ImportUnmanagedPackageRequestSchema": "api/ImportUnmanagedPackageRequest", + "ImportUnmanagedPackageResponse": "api/ImportUnmanagedPackageResponse", + "ImportUnmanagedPackageResponseSchema": "api/ImportUnmanagedPackageResponse", "ImportValidationConfig": "api/ImportValidationConfig", "ImportValidationConfigSchema": "api/ImportValidationConfig", "ImportValidationMode": "api/ImportValidationMode", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index b5038b9480e..3f06b7fee54 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -510,6 +510,12 @@ "ImportResponseSchema": "src/api/export.zod.ts#ImportResponseSchema (const)", "ImportRowResult": "src/api/export.zod.ts#ImportRowResult (type)", "ImportRowResultSchema": "src/api/export.zod.ts#ImportRowResultSchema (const)", + "ImportUnmanagedPackageRequest": "src/api/package-lifecycle.zod.ts#ImportUnmanagedPackageRequest (type)", + "ImportUnmanagedPackageRequestParsed": "src/api/package-lifecycle.zod.ts#ImportUnmanagedPackageRequestParsed (type)", + "ImportUnmanagedPackageRequestSchema": "src/api/package-lifecycle.zod.ts#ImportUnmanagedPackageRequestSchema (const)", + "ImportUnmanagedPackageResponse": "src/api/package-lifecycle.zod.ts#ImportUnmanagedPackageResponse (type)", + "ImportUnmanagedPackageResponseParsed": "src/api/package-lifecycle.zod.ts#ImportUnmanagedPackageResponseParsed (type)", + "ImportUnmanagedPackageResponseSchema": "src/api/package-lifecycle.zod.ts#ImportUnmanagedPackageResponseSchema (const)", "ImportValidationConfig": "src/api/export.zod.ts#ImportValidationConfig (type)", "ImportValidationConfigParsed": "src/api/export.zod.ts#ImportValidationConfigParsed (type)", "ImportValidationConfigSchema": "src/api/export.zod.ts#ImportValidationConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 0555256309f..d5ffaa15fd6 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -217,6 +217,8 @@ "api/ImportRequest", "api/ImportResponse", "api/ImportRowResult", + "api/ImportUnmanagedPackageRequest", + "api/ImportUnmanagedPackageResponse", "api/ImportValidationConfig", "api/ImportValidationMode", "api/ImportValidationResult", From b5cfa28ee04003d2c17396baece1d7d8db89c65b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:00:10 +0000 Subject: [PATCH 5/6] fix(metadata-protocol): ask isArtifactBacked alone for managed collisions; pin the handshake refusal Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/package-unmanaged-install.test.ts | 14 +++++++++++++- packages/metadata-protocol/src/protocol.ts | 10 +++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/metadata-protocol/src/package-unmanaged-install.test.ts b/packages/metadata-protocol/src/package-unmanaged-install.test.ts index 1f128c3a70b..aa1e4df1be1 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.test.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.test.ts @@ -476,7 +476,7 @@ describe('importUnmanagedPackage — the verb', () => { const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: crmBody() })); expect(resolveThrownHttpError(err).code).toBe('RESOURCE_CONFLICT'); expect(err.details.collisions).toEqual([ - { type: 'view', name: 'crm_lead_open', heldBy: "shipped by the managed package 'com.example.other'" }, + { type: 'view', name: 'crm_lead_open', heldBy: 'shipped by a managed package' }, ]); expect(packages.size).toBe(0); }); @@ -500,6 +500,18 @@ describe('importUnmanagedPackage — the verb', () => { expect(writes).toEqual([]); }); + it('refuses a package built for another protocol major before the base exists (ADR-0087 D1)', async () => { + const { protocol, writes, packages } = makeProtocol(); + const err = await refusalOf(() => protocol.importUnmanagedPackage({ + manifest: crmBody({ engines: { protocol: '^1' } }), + })); + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(422); + expect(envelope.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(writes).toEqual([]); + expect(packages.size).toBe(0); + }); + it('refuses a request key its published declaration does not declare — 400, nothing written', async () => { const { protocol, writes } = makeProtocol(); const err = await refusalOf(() => protocol.importUnmanagedPackage({ manifest: crmBody(), installMode: 'unmanaged' })); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f6e22adfb51..432f1d78418 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -24620,13 +24620,13 @@ export class ObjectStackProtocolImplementation implements } catch (error) { this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } + // The managed side asks the save door's own predicate and nothing else: + // it answers whether a package ships the name, not which one, and a + // second spelling of the registry lookup to name it would be the + // drift the predicate exists to prevent. for (const [key, slot] of wanted) { if (found.has(key) || !this.isArtifactBacked(slot.type, slot.name)) continue; - const owner = (this.lookupArtifactItem(slot.type, slot.name) as { _packageId?: unknown } | undefined)?._packageId; - found.set(key, { - ...slot, - heldBy: typeof owner === 'string' ? `shipped by the managed package '${owner}'` : 'shipped by a managed package', - }); + found.set(key, { ...slot, heldBy: 'shipped by a managed package' }); } return [...found.values()]; } From d15160d1087c0c96478872177e061f4d8998487e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:05:16 +0000 Subject: [PATCH 6/6] test(metadata-protocol): the import's stub find honours limit; pin its engine double Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/package-unmanaged-install.test.ts | 6 ++++-- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/package-unmanaged-install.test.ts b/packages/metadata-protocol/src/package-unmanaged-install.test.ts index aa1e4df1be1..7512612cac8 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.test.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.test.ts @@ -80,8 +80,10 @@ function makeEngine(opts: { artifacts?: Array<{ type: string; name: string; pack const engine: any = { async syncObjectSchema(name: any) { ddl.push(`sync ${typeof name === 'string' ? name : name?.name}`); }, async dropObjectSchema(name: any) { ddl.push(`drop ${typeof name === 'string' ? name : name?.name}`); }, - async find(t: string, q?: { where?: Record }) { - return table(t).filter((r) => matches(r, q?.where)); + async find(t: string, q?: { where?: Record; limit?: number }) { + const rows = table(t).filter((r) => matches(r, q?.where)); + // The caller's bound, after the filter, by presence — as the engine applies it. + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, async findOne(t: string, q: { where: Record }) { assertEngineFindOnePredicate(t, q); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 83b7f7dc95c..8ff5b60bf3a 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -136,6 +136,21 @@ "verb": "update", "pinned": 4 }, + { + "file": "packages/metadata-protocol/src/package-unmanaged-install.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/package-unmanaged-install.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/package-unmanaged-install.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts", "verb": "findOne",