From 98e6c224acac7dd81d006f215a200b2af5779e89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:18:40 +0000 Subject: [PATCH 01/10] wip(#29594): thread the installed-manifest lookup through every pair site; kernel verdict on the ensure door Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- .../src/utils/override-declarations.test.ts | 3 +- .../cli/src/utils/override-declarations.ts | 11 ++- ...tplace-install-local-override-gate.test.ts | 3 +- .../src/marketplace-install-local-plugin.ts | 24 ++++- .../src/validate-customization-namespace.ts | 9 +- .../src/customization-pair-lookup.test.ts | 89 +++++++++++++++---- .../src/customization-pair-lookup.ts | 75 +++++++++++++--- .../src/installed-manifests.ts | 22 +++++ .../src/protocol-class-head.ts | 5 +- .../src/protocol-customization-package.ts | 40 ++++++++- .../src/protocol-diff-delete-load.ts | 3 + .../src/protocol-discovery-and-bases.ts | 5 +- .../src/protocol-environment-rows.ts | 19 ++-- .../src/protocol-lock-state.ts | 12 ++- .../src/protocol-meta-layered-reads.ts | 8 +- .../src/protocol-meta-list-reads.ts | 18 ++-- .../src/protocol-overlay-merge.ts | 87 ++++++++++++------ .../src/protocol-package-bindings.ts | 4 +- .../src/protocol-packaged-base-refusal.ts | 57 ++++++++---- .../protocol-packageless-overlay-migration.ts | 20 +++-- .../src/protocol-registry-hydration.ts | 4 +- .../src/protocol-served-overrides.ts | 9 +- .../src/protocol-write-gates.ts | 10 ++- .../protocol.packaged-base-refusal.test.ts | 4 +- packages/metadata-protocol/src/protocol.ts | 12 +++ .../src/served-override.test.ts | 60 +++++++------ .../metadata-protocol/src/served-override.ts | 15 ++-- .../src/stored-flow-slot.test.ts | 32 ++++--- .../metadata-protocol/src/stored-flow-slot.ts | 10 ++- .../src/plugin-authored-actions.test.ts | 6 +- packages/objectql/src/plugin.ts | 7 +- ...col-custom-override-effective-read.test.ts | 7 +- .../src/protocol-flow-custom-override.test.ts | 7 +- ...ocol-packageless-overlay-migration.test.ts | 15 ++-- packages/objectql/src/registry.ts | 5 +- .../packages-ensure-customization.test.ts | 50 +++++++++-- packages/runtime/src/domains/packages.ts | 29 +++--- .../src/flow-name-shadowing.test.ts | 20 +++-- .../src/flow-precedence-loader-set.test.ts | 42 +++++---- .../service-automation/src/flow-precedence.ts | 31 ++++--- .../services/service-automation/src/plugin.ts | 18 ++++ .../services/service-datasource/src/plugin.ts | 12 +-- 42 files changed, 658 insertions(+), 261 deletions(-) create mode 100644 packages/metadata-protocol/src/installed-manifests.ts diff --git a/packages/cli/src/utils/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts index 287ec5bac79..bd874bee906 100644 --- a/packages/cli/src/utils/override-declarations.test.ts +++ b/packages/cli/src/utils/override-declarations.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from 'vitest'; import { judgeOverrideWrite } from '@objectstack/metadata-protocol'; import { findOverrideDeclarationRefusals } from './override-declarations.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; const BASE = 'com.acme.crm'; const PAIR = 'com.acme.crm.custom'; @@ -33,7 +34,7 @@ const base = pkg(BASE, [], { }); const judged = (type: string, name: string, packageId: string, item: unknown, dependencies: string[], shippers: string[]) => { - const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers }); + const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers, installed: installedManifestsOf(undefined) }); if (v.verdict !== 'refuse') throw new Error(`expected a refusal, got ${v.verdict}`); return v.message; }; diff --git a/packages/cli/src/utils/override-declarations.ts b/packages/cli/src/utils/override-declarations.ts index 9868c00fa50..46376a15495 100644 --- a/packages/cli/src/utils/override-declarations.ts +++ b/packages/cli/src/utils/override-declarations.ts @@ -38,6 +38,7 @@ import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem } from '@objectstack/metadata-protocol'; import { OverrideDeclarationSchema } from '@objectstack/spec/kernel'; +import type { InstalledManifestLookup } from '@objectstack/metadata-core'; import { artifactPackages } from './artifact-packages.js'; @@ -56,6 +57,8 @@ export interface OverrideDeclarationRefusalRow { interface WalkedPackage { id: string; + /** The package's manifest as the artifact carries it. */ + manifest: Record | undefined; dependencies: string[]; items: OverrideWriteItem[]; } @@ -75,6 +78,7 @@ function packagesOf(parsed: Record): WalkedPackage[] { if (entries.length > 0) { return entries.map(({ index, id, body }) => ({ id, + manifest: body, dependencies: dependencyIds(body), items: overrideWriteItemsOf(body, `packages[${index}].manifest.`), })); @@ -82,7 +86,7 @@ function packagesOf(parsed: Record): WalkedPackage[] { const manifest = isRecord(parsed.manifest) ? parsed.manifest : undefined; const id = typeof manifest?.id === 'string' && manifest.id !== '' ? manifest.id : undefined; if (id === undefined) return []; - return [{ id, dependencies: dependencyIds(manifest), items: overrideWriteItemsOf(parsed, '') }]; + return [{ id, manifest, dependencies: dependencyIds(manifest), items: overrideWriteItemsOf(parsed, '') }]; } /** `id`'s dependency closure, followed through the packages this artifact carries. */ @@ -107,6 +111,9 @@ export function findOverrideDeclarationRefusals(parsed: Record) const packages = packagesOf(parsed); const byId = new Map(packages.map((p) => [p.id, p])); const shipped = new Map(packages.map((p) => [p.id, new Set(p.items.map((i) => `${i.type}\u0000${i.identity}`))])); + // The manifests this artifact carries, as the pair lookup reads installed + // ones: who may declare an override of a base is its App's pair. + const installed: InstalledManifestLookup = (packageId) => byId.get(packageId)?.manifest; const rows: OverrideDeclarationRefusalRow[] = []; for (const pkg of packages) { const dependencies = closureOf(pkg.id, byId); @@ -133,7 +140,7 @@ export function findOverrideDeclarationRefusals(parsed: Record) (byId.get(baseId)?.items ?? []).filter((i) => i.type === walked.type).map((i) => i.item); const verdict = judgeOverrideWrite( { type: walked.type, name: walked.name, packageId: pkg.id, item: walked.item }, - { dependencies, shippers, baseItemOf, baseItemsOf }, + { dependencies, shippers, baseItemOf, baseItemsOf, installed }, ); if (verdict.verdict !== 'refuse') continue; rows.push({ path: walked.path, code: 'NOT_OVERRIDABLE', rule: verdict.rule, packageId: pkg.id, message: verdict.message }); diff --git a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts index 883b699ba58..affc552730e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts @@ -32,6 +32,7 @@ import '@objectstack/runtime'; import { judgeOverrideWrite } from '@objectstack/metadata-protocol'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; type Handler = (c: any) => Promise; @@ -163,7 +164,7 @@ describe('install-local 2d — the override write gate at the managed install (A expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE'); const verdict = judgeOverrideWrite( { type: 'dashboard', name: 'crm_board', packageId: PARTNER, item }, - { dependencies: [BASE], shippers: [BASE] }, + { dependencies: [BASE], shippers: [BASE], installed: installedManifestsOf(undefined) }, ); expect(res.payload.error.details.refusals).toEqual([ { type: 'dashboard', name: 'crm_board', rule: 'undeclared_same_name', message: (verdict as any).message }, diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 975487202cf..ed56adcb155 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -237,6 +237,10 @@ import { fieldPatchTargetRefusals, fieldPatchTargetRefusalMessage, type FieldPatchTargetRefusal, + // [ADR-0146 D3 · ruling record 6691022506] The installed manifests the + // override gate's pair lookup reads. + installedManifestsOf, + type InstalledManifestLookup, } from '@objectstack/metadata-core'; import { resolveCloudUrl } from './cloud-url.js'; import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; @@ -3803,7 +3807,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // each under its own id. A delivered package is in no registry yet: its // dependencies and its items are read off the body, and it registers // read-only, so it is a base a sibling's closure can hold. - const delivered = new Map(); + const delivered = new Map(); const entries: Record[] = Array.isArray(converted.packages) ? converted.packages .map((entry: unknown) => (isPlainObject(entry) && isPlainObject(entry.manifest) ? entry.manifest : undefined)) @@ -3812,8 +3816,17 @@ export class MarketplaceInstallLocalPlugin implements Plugin { for (const body of entries) { const id = entries.length === 1 && !Array.isArray(converted.packages) ? manifestId : manifestIdOf(body); if (typeof id !== 'string' || id === '') continue; - delivered.set(id, { dependencies: dependencyIdsOf(declaredPackageManifest(body)), items: overrideWriteItemsOf(body) }); + const manifest = declaredPackageManifest(body); + delivered.set(id, { manifest, dependencies: dependencyIdsOf(manifest), items: overrideWriteItemsOf(body) }); } + // The manifests the pair lookup reads: a delivered package's own, + // else the installed one — who may declare an override of a base is + // its App's pair (`pairOf`). + const installedRegistry = installedManifestsOf(registry); + const manifestOf: InstalledManifestLookup = (id) => { + const local = delivered.get(id)?.manifest; + return isPlainObject(local) ? local : installedRegistry(id); + }; const dependenciesOf = (id: string): string[] => delivered.get(id)?.dependencies ?? dependencyIdsOf((registry.getPackage?.(id) as { manifest?: unknown } | undefined)?.manifest); const closureOf = (id: string): string[] => { @@ -3892,7 +3905,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } const verdict = judgeOverrideWrite( { type: item.type, name: item.name, packageId, item: item.item }, - { dependencies: closure, shippers, baseItemOf: (base) => shipped.get(base), baseItemsOf: (base) => listed.get(base) ?? [] }, + { + dependencies: closure, shippers, baseItemOf: (base) => shipped.get(base), + baseItemsOf: (base) => listed.get(base) ?? [], installed: manifestOf, + }, ); if (verdict.verdict === 'refuse') { refusals.push({ @@ -3918,7 +3934,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (holding === undefined) continue; const verdict = judgeOverrideWrite( { type: item.type, name: item.name, packageId: dependent, item: holding }, - { dependencies: dependentClosure, shippers: [baseId] }, + { dependencies: dependentClosure, shippers: [baseId], installed: manifestOf }, ); if (verdict.verdict === 'refuse') { collisions.push({ type: item.type, name: item.name, packageId: dependent, rule: verdict.rule, message: verdict.message }); diff --git a/packages/lint/src/validate-customization-namespace.ts b/packages/lint/src/validate-customization-namespace.ts index 51500056606..3c04712027c 100644 --- a/packages/lint/src/validate-customization-namespace.ts +++ b/packages/lint/src/validate-customization-namespace.ts @@ -33,8 +33,8 @@ * * ## Not judged here * - * - A package that is itself a pair (`customizationPackageId` answers - * `base_is_customization_package`): a pair is never paired again, and its + * - A package that is itself a pair (`isCustomizationPackage`, the one + * spelling of "some base derives this id"): a pair is never paired again, and its * namespace is the pair namespace, longer than a base's by construction. * - The helper's other two refusals. A missing namespace is not this rule's * question, and a namespace the manifest grammar refuses for another reason @@ -53,7 +53,7 @@ import { CUSTOMIZATION_NAMESPACE_SUFFIX, MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH, customizationNamespace, - customizationPackageId, + isCustomizationPackage, } from '@objectstack/spec/kernel'; import type { AnyRec } from './page-walk.js'; @@ -105,8 +105,7 @@ export function validateCustomizationNamespace(stack: AnyRec): CustomizationName if (!isRec(stack)) return findings; for (const { manifest, at } of deliveredManifests(stack)) { - const pairId = customizationPackageId(manifest.id); - if (!pairId.ok && pairId.reason === 'base_is_customization_package') continue; + if (isCustomizationPackage(manifest.id)) continue; const pairNamespace = customizationNamespace(manifest.namespace); if (pairNamespace.ok || pairNamespace.reason !== 'base_namespace_too_long') continue; diff --git a/packages/metadata-core/src/customization-pair-lookup.test.ts b/packages/metadata-core/src/customization-pair-lookup.test.ts index 440ca1adca3..edb009b539f 100644 --- a/packages/metadata-core/src/customization-pair-lookup.test.ts +++ b/packages/metadata-core/src/customization-pair-lookup.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import { customizationPackageId, isCustomizationPairOf } from '@objectstack/spec/kernel'; -import { appOf, isPairOf, pairOf } from './customization-pair-lookup.js'; +import { + appOf, + installedManifestsOf, + isPairOf, + pairOf, + type InstalledManifestLookup, +} from './customization-pair-lookup.js'; /** * The lookup answers exactly what the pure id rule answered for every package @@ -10,10 +16,16 @@ import { appOf, isPairOf, pairOf } from './customization-pair-lookup.js'; * package, a `sys` kernel package, a manifest with no namespace — because * `appOf` is the identity until the ensure door resolves a member to its App. * Each row pins the literal pair, so a resolver that moves one package to - * another App reds that row by name. The rows' `type`, `scope` and `namespace` - * are read by nothing today: they are the App resolver's inputs when it lands. + * another App reds that row by name. Every row is asked twice: with nothing + * installed, and with all the rows installed — the module declaring its App + * the way HotCRM's modules do — so the lookup the callers now hand in is + * proven to move nothing while `appOf` stays the identity. */ -const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string; scope?: string; namespace?: string }; pair: string }> = [ +const ROWS: ReadonlyArray<{ + shape: string; + manifest: { id: string; type?: string; scope?: string; namespace?: string; dependencies?: Record }; + pair: string; +}> = [ { shape: 'an App package', manifest: { id: 'app.objectstack.hotcrm', type: 'app', namespace: 'crm' }, @@ -21,7 +33,12 @@ const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string }, { shape: 'a module an App artifact brings (still its own pair today)', - manifest: { id: 'app.objectstack.hotcrm.service', type: 'module', namespace: 'crm' }, + manifest: { + id: 'app.objectstack.hotcrm.service', + type: 'module', + namespace: 'crm', + dependencies: { 'app.objectstack.hotcrm': '^6.1.0' }, + }, pair: 'app.objectstack.hotcrm.service.custom', }, { @@ -41,11 +58,43 @@ const ROWS: ReadonlyArray<{ shape: string; manifest: { id: string; type?: string }, ]; +const NOTHING_INSTALLED = installedManifestsOf(undefined); +const ALL_INSTALLED = installedManifestsOf({ + getPackage: (id: string) => { + const row = ROWS.find((r) => r.manifest.id === id); + return row ? { manifest: row.manifest } : undefined; + }, +}); +const LOOKUPS: ReadonlyArray<{ installed: string; lookup: InstalledManifestLookup }> = [ + { installed: 'nothing installed', lookup: NOTHING_INSTALLED }, + { installed: 'every row installed', lookup: ALL_INSTALLED }, +]; +const ROWS_BY_LOOKUP = LOOKUPS.flatMap(({ installed, lookup }) => ROWS.map((row) => ({ ...row, installed, lookup }))); + +describe('installedManifestsOf — the registry read the lookup is', () => { + it('reads the installed manifest by id, and nothing for an id not installed', () => { + const service = ROWS[1]!.manifest; + expect(ALL_INSTALLED(service.id)).toBe(service); + expect(ALL_INSTALLED('com.example.absent')).toBeUndefined(); + }); + + it.each([ + { source: 'no registry', registry: undefined }, + { source: 'a null registry', registry: null }, + { source: 'a registry with no getPackage', registry: {} }, + { source: 'a package with no manifest', registry: { getPackage: () => ({}) } }, + { source: 'a manifest that is not an object', registry: { getPackage: () => ({ manifest: 'app' }) } }, + { source: 'a null manifest', registry: { getPackage: () => ({ manifest: null }) } }, + ])('answers undefined for $source', ({ registry }) => { + expect(installedManifestsOf(registry)('app.objectstack.hotcrm')).toBeUndefined(); + }); +}); + describe('pairOf — the pair of a package, looked up through its App', () => { - it.each(ROWS)('$shape: answers the old derivation, $pair', ({ manifest, pair }) => { - expect(appOf(manifest.id)).toBe(manifest.id); - expect(pairOf(manifest.id)).toEqual(customizationPackageId(manifest.id)); - expect(pairOf(manifest.id)).toEqual({ ok: true, value: pair }); + it.each(ROWS_BY_LOOKUP)('$shape, $installed: answers the old derivation, $pair', ({ manifest, pair, lookup }) => { + expect(appOf(manifest.id, lookup)).toBe(manifest.id); + expect(pairOf(manifest.id, lookup)).toEqual(customizationPackageId(manifest.id)); + expect(pairOf(manifest.id, lookup)).toEqual({ ok: true, value: pair }); }); it.each([ @@ -55,24 +104,26 @@ describe('pairOf — the pair of a package, looked up through its App', () => { { input: 'hotcrm.custom', reason: 'base_is_customization_package' }, { input: 'Com.Acme', reason: 'base_id_invalid' }, ])('refuses $input as the id rule does: $reason', ({ input, reason }) => { - expect(pairOf(input)).toEqual(customizationPackageId(input)); - expect(pairOf(input)).toEqual({ ok: false, reason }); + for (const { lookup } of LOOKUPS) { + expect(pairOf(input, lookup)).toEqual(customizationPackageId(input)); + expect(pairOf(input, lookup)).toEqual({ ok: false, reason }); + } }); }); describe('isPairOf — is this THE pair of that package', () => { - it.each(ROWS)('$shape: only $pair is its pair', ({ manifest, pair }) => { + it.each(ROWS_BY_LOOKUP)('$shape, $installed: only $pair is its pair', ({ manifest, pair, lookup }) => { const candidates = [pair, manifest.id, `${pair}.custom`, 'app.objectstack.hotcrm.custom', 'partner.ext', null, undefined]; for (const candidate of candidates) { - expect(isPairOf(candidate, manifest.id)).toBe(isCustomizationPairOf(candidate, manifest.id)); + expect(isPairOf(candidate, manifest.id, lookup)).toBe(isCustomizationPairOf(candidate, manifest.id)); } - expect(isPairOf(pair, manifest.id)).toBe(true); - expect(isPairOf(manifest.id, manifest.id)).toBe(false); + expect(isPairOf(pair, manifest.id, lookup)).toBe(true); + expect(isPairOf(manifest.id, manifest.id, lookup)).toBe(false); }); - it('a pair has no pair, and a missing base has none', () => { - expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom')).toBe(false); - expect(isPairOf('.custom', '')).toBe(false); - expect(isPairOf('x.custom', undefined)).toBe(false); + it.each(LOOKUPS)('$installed: a pair has no pair, and a missing base has none', ({ lookup }) => { + expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom', lookup)).toBe(false); + expect(isPairOf('.custom', '', lookup)).toBe(false); + expect(isPairOf('x.custom', undefined, lookup)).toBe(false); }); }); diff --git a/packages/metadata-core/src/customization-pair-lookup.ts b/packages/metadata-core/src/customization-pair-lookup.ts index bd176c7702a..77f87a5e289 100644 --- a/packages/metadata-core/src/customization-pair-lookup.ts +++ b/packages/metadata-core/src/customization-pair-lookup.ts @@ -23,11 +23,15 @@ * `customizationBasePackageId`, `isCustomizationPackage`) is unaffected and * stays with the spec helpers. * - * Today {@link appOf} is the identity: every package is its own App for + * Every question carries the caller's {@link InstalledManifestLookup} — the + * installed manifests of the registry it serves ({@link installedManifestsOf}), + * the only record of which App a member belongs to. Today {@link appOf} reads + * none of it and is the identity: every package is its own App for * customization, so {@link pairOf} answers exactly what * `customizationPackageId` answers, refusals included. Resolving a member - * package to the App whose artifact brought it is the ensure door's change - * (ADR-0146 D3 as amended), not this module's. + * package to its App from that lookup is the next change (ADR-0146 D3 as + * amended, merge question 11), and it lands in {@link appOf} alone because + * every caller already hands the lookup in. * * Why this package: every caller that asks the question — * `@objectstack/metadata-protocol`, `@objectstack/objectql`, @@ -44,15 +48,62 @@ import { type CustomizationPackageIdRefusal, } from '@objectstack/spec/kernel'; +/** + * What the App lookup may read of an installed package: its manifest's + * `type`, `scope`, `namespace` and `dependencies` — the declarations the + * registry holds (ADR-0146 D3 as amended, merge question 11: nothing in the + * tree persists which artifact delivered a package, so the App of a package + * is read from what the installed packages declare). + */ +export interface AppLookupManifest { + readonly type?: unknown; + readonly scope?: unknown; + readonly namespace?: unknown; + readonly dependencies?: unknown; +} + +/** + * The installed manifest of a package id, or `undefined` when no package of + * that id is installed — a registry's `getPackage(id)?.manifest`. Every + * caller of {@link appOf}, {@link pairOf} and {@link isPairOf} passes the + * lookup of the registry it serves; {@link installedManifestsOf} builds one. + */ +export type InstalledManifestLookup = (packageId: string) => AppLookupManifest | undefined; + +/** A registry, as far as {@link installedManifestsOf} reads it. */ +export interface InstalledPackageSource { + getPackage?(id: string): { readonly manifest?: unknown } | undefined; +} + +/** The lookup of a registry holding nothing: every package is absent. */ +const NOTHING_INSTALLED: InstalledManifestLookup = () => undefined; + +/** + * The {@link InstalledManifestLookup} over `registry`'s installed packages. A + * missing registry (or one without `getPackage`) holds nothing, so every + * package reads as absent — and an absent package is its own unit, which is + * what a lookup with no registry to read can truthfully answer. + */ +export function installedManifestsOf(registry: InstalledPackageSource | null | undefined): InstalledManifestLookup { + if (!registry || typeof registry.getPackage !== 'function') return NOTHING_INSTALLED; + return (packageId) => { + const manifest = registry.getPackage!(packageId)?.manifest; + return typeof manifest === 'object' && manifest !== null ? (manifest as AppLookupManifest) : undefined; + }; +} + /** * The App that carries `packageId` for customization: the id whose pair holds - * every customization of `packageId`'s items. + * every customization of `packageId`'s items, read through `installed` (the + * registry the caller serves). * - * Today the identity — every package is its own App for this purpose. The - * lookup is the seam the App-unit ruling moves; its callers never assume the - * identity. + * Today the identity — every package is its own App for this purpose, and + * `installed` is not read yet. The lookup is the seam the App-unit ruling + * moves; its callers never assume the identity, and each already hands it the + * registry it will read. */ -export function appOf(packageId: string): string { +export function appOf(packageId: string, installed: InstalledManifestLookup): string { + void installed; return packageId; } @@ -61,8 +112,8 @@ export function appOf(packageId: string): string { * it, or the refusal `customizationPackageId` names (`base_id_missing`, * `base_is_customization_package`, `base_id_invalid`). */ -export function pairOf(base: unknown): CustomizationDerivation { - return customizationPackageId(typeof base === 'string' && base.length > 0 ? appOf(base) : base); +export function pairOf(base: unknown, installed: InstalledManifestLookup): CustomizationDerivation { + return customizationPackageId(typeof base === 'string' && base.length > 0 ? appOf(base, installed) : base); } /** @@ -70,7 +121,7 @@ export function pairOf(base: unknown): CustomizationDerivation, singularType: string, + static restoreWriteFor(host: Pick, singularType: string, name: string, binding: string | null, artifactBacked: boolean, source: string,): { intent: 'override-artifact' | 'runtime-only'; deriveRestoredBody: (historyBody: unknown) => Promise } { const owner = artifactBacked ? host.packagedArtifactOwner({ type: singularType, name }) : undefined; - const pairRow = owner !== undefined && isPairOf(binding, owner); + const pairRow = owner !== undefined && isPairOf(binding, owner, installedManifestsOfEngine(host.engine)); const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !pairRow ? 'override-artifact' : 'runtime-only'; const writeRestored = host.restoredBodyWriter(singularType, name, binding, source); const deriveRestoredBody = pairRow diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts index 34d023f6178..97707685edd 100644 --- a/packages/metadata-protocol/src/protocol-customization-package.ts +++ b/packages/metadata-protocol/src/protocol-customization-package.ts @@ -60,7 +60,10 @@ * - `400` — the request is malformed, or the base id has no pair (it is * itself a pair, or `.custom` is not a valid package id); * - `404` — the base is not installed; - * - `409` — the base's state rules the pair out: the base is writable + * - `409` — the base's state rules the pair out: the base is a kernel + * package ({@link kernelPackageScopeOf}), whose objects are not + * customizable in this version (ruling record 6691217245 on ADR-0146 D3); + * the base is writable * (customize it directly), declares no namespace, or has a namespace whose * pair namespace fails the manifest grammar (longer than * {@link MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH}, no truncation and no @@ -89,7 +92,7 @@ */ import type { ObjectStackProtocolImplementation } from './protocol.js'; -import { isWritablePackage } from './package-writability.js'; +import { isWritablePackage, READ_ONLY_PACKAGE_SCOPES } from './package-writability.js'; import { EnsureCustomizationPackageRequestSchema } from '@objectstack/spec/api'; import type { EnsureCustomizationPackageRequest, @@ -103,6 +106,7 @@ import { customizationNamespace, } from '@objectstack/spec/kernel'; import { pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import type { CustomizationNamespaceRefusal } from '@objectstack/spec/kernel'; /** The HTTP route that serves the door — named by every refusal that points at it. */ @@ -141,9 +145,26 @@ interface PairPackageRow { version?: unknown; namespace?: unknown; dependencies?: unknown; + scope?: unknown; }; } +/** + * [ADR-0146 D3, ruling record 6691217245] The kernel scope of an installed + * package — `system` or `cloud`, the scopes the platform delivers + * ({@link READ_ONLY_PACKAGE_SCOPES}) — or `undefined` for any other package. + * The kernel is one suite that ships with the runtime at the runtime's version + * and is always installed whole, so it is no customer's unit to customize: in + * this version every kernel object is served as not customizable, and the + * ensure door creates no pair for a kernel package. Which kernel objects a + * later version opens to customization (a whitelist) is that version's + * question, not this predicate's. + */ +function kernelPackageScopeOf(row: PairPackageRow | undefined): string | undefined { + const scope = row?.manifest?.scope; + return typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope) ? scope : undefined; +} + /** * The calls in flight, per registry and per pair id — see the module header, * "Idempotent, and one pair under concurrency". Keyed weakly by the registry, @@ -252,7 +273,7 @@ export async function ensureCustomizationPackage( } const baseId = declared.data.basePackageId; - const pair = pairOf(baseId); + const pair = pairOf(baseId, installedManifestsOfEngine(this.engine)); if (!pair.ok) { throw refusal(400, pair.reason === 'base_is_customization_package' ? `Package '${baseId}' is itself a paired customization package, and a pair is never paired again: ` @@ -310,6 +331,19 @@ export function customizationPairEnsureRefusal( return refusal(404, `Package '${baseId}' not found: a paired customization package is ensured for an installed ` + 'base.'); } + // [ruling record 6691217245] A kernel package has no pair in this + // version, ensured or created — the door's verdict, before anything else + // about the base is judged. It refuses the ENSURE only: a pair an earlier + // version of this door created for a kernel package stays installed, and + // the overrides inside it stay served and writable (the override gate and + // the served-override read never ask this door). + const kernelScope = kernelPackageScopeOf(base); + if (kernelScope !== undefined) { + return refusal(409, `Package '${baseId}' is a kernel package (scope '${kernelScope}'): kernel objects are not ` + + `customizable in this version, so '${baseId}' has no paired customization package and '${pairId}' is ` + + 'not ensured. A pair an earlier version created stays installed, and the overrides in it stay served ' + + '(ADR-0146 D3).'); + } // ADR-0070 D2's one predicate. A writable base is the customer's own and // takes their changes directly; the pair exists for a base the customer // cannot author into. diff --git a/packages/metadata-protocol/src/protocol-diff-delete-load.ts b/packages/metadata-protocol/src/protocol-diff-delete-load.ts index 9a30d5d88df..9df68e01d38 100644 --- a/packages/metadata-protocol/src/protocol-diff-delete-load.ts +++ b/packages/metadata-protocol/src/protocol-diff-delete-load.ts @@ -38,6 +38,7 @@ import { import { isMissingTableError } from '@objectstack/metadata/errors'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import type { ServedOverrideRow } from './served-override.js'; import { REFERENCE_SITES } from './reference-sites.js'; import { computeMetadataDiagnostics } from './metadata-diagnostics.js'; @@ -426,6 +427,7 @@ export class ProtocolDiffDeleteLoadFacet { // is ({@link isPairRowRemoval}). const pairRowRemoval = isPairRowRemoval( request.type, host.packagedArtifactOwner({ type: request.type, name: request.name }), request.packageId, + installedManifestsOfEngine(host.engine), ); // Two-tier authorization for delete (mirrors saveMetaItem). // • Artifact-backed item → delete becomes a tombstone overlay, @@ -1164,6 +1166,7 @@ export class ProtocolDiffDeleteLoadFacet { && !isServedOverrideRowOf( record as ServedOverrideRow, host.packagedArtifactOwner({ type: normalizedType, name: String(record.name) }), + installedManifestsOfEngine(host.engine), ) ) { sealedOverlays.push({ type: normalizedType, name: String(record.name) }); diff --git a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts index 77a0451a7f7..4ad531afa4a 100644 --- a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts +++ b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts @@ -47,6 +47,7 @@ import { getMetadataTypeSchema, } from '@objectstack/spec/kernel'; import { pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { storedBodyForCarryForward } from './protocol-credential-carry-forward.js'; import type { Equal, MetaTypesListing } from './protocol-meta-types-listing.js'; import { @@ -405,9 +406,9 @@ export class ProtocolDiscoveryAndBasesFacet { * So the bundle is exactly the pair's share of the catalog the deployment * serves, with its package kept. */ - static async getPairTranslationBundle(host: Pick, basePackageId: string): Promise { + static async getPairTranslationBundle(host: Pick, basePackageId: string): Promise { if (typeof basePackageId !== 'string' || basePackageId === '') return undefined; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installedManifestsOfEngine(host.engine)); if (!pair.ok) return undefined; const layer = await readAuthoredTranslationLayer({ find: (table: string, opts: { where?: Record; [key: string]: unknown } = {}) => diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts index 4f8f77ab777..96d30a0cef4 100644 --- a/packages/metadata-protocol/src/protocol-environment-rows.ts +++ b/packages/metadata-protocol/src/protocol-environment-rows.ts @@ -44,13 +44,14 @@ * not move. */ -import { pairOf } from '@objectstack/metadata-core'; +import { pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import type { StoredOverlayRow } from './item-lock.js'; import { overlayMemberWhere } from './migrations/overlay-index.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; import { rowMatchesOverlayWhere, servedOverlayRowCandidates } from './protocol.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -96,7 +97,7 @@ export async function findEnvironmentRow(this: Pick, + this: Pick, args: { type: string; name: string; @@ -117,7 +118,8 @@ export async function findServedOverlayRow( // collision would serve package B's customization for a package A // read); with no package context (legacy/runtime reader), any row. const other = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; - for (const candidate of servedOverlayRowCandidates({ packageId: args.packageId, servedOverride: args.servedOverride })) { + const installed = installedManifestsOfEngine(this.engine); + for (const candidate of servedOverlayRowCandidates({ packageId: args.packageId, servedOverride: args.servedOverride, installed })) { const type = candidate.spelling === 'canonical' ? args.type : other; if (type === undefined) continue; const where: Record = { @@ -142,7 +144,7 @@ export async function findServedOverlayRow( // package ({@link isServedOverrideRowOf}, on the row's own binding and // body): an undeclared row of the pair is the pair's own item. if (candidate.overrideOf !== undefined) { - if (isServedOverrideRowOf(row, candidate.overrideOf)) return { row, overrideOf: candidate.overrideOf }; + if (isServedOverrideRowOf(row, candidate.overrideOf, installed)) return { row, overrideOf: candidate.overrideOf }; continue; } return { row }; @@ -290,9 +292,14 @@ export function soleShippingPackage(shipped: readonly unknown[]): string | undef * PR #28777). A declared override is served as the base's item by the * served-row resolution and is never asked about here. */ -export function baseAddressServesMember(base: string, bound: string | undefined, baseShipsMember: boolean): boolean { +export function baseAddressServesMember( + base: string, + bound: string | undefined, + baseShipsMember: boolean, + installed: InstalledManifestLookup, +): boolean { if (bound === undefined || bound === base) return true; if (baseShipsMember) return false; - const pair = pairOf(base); + const pair = pairOf(base, installed); return pair.ok && pair.value === bound; } diff --git a/packages/metadata-protocol/src/protocol-lock-state.ts b/packages/metadata-protocol/src/protocol-lock-state.ts index 622ca97d212..ba35543e7a1 100644 --- a/packages/metadata-protocol/src/protocol-lock-state.ts +++ b/packages/metadata-protocol/src/protocol-lock-state.ts @@ -39,7 +39,8 @@ * keeps calling the class method, the REST and runtime published doors among * them. * - * The bodies read no field, so the host carries no getter. + * One body reads a field, `engine` (the registry the customization pair + * lookup reads, `installedManifestsOfEngine`), handed over as a getter. * * The one module-level declaration of `protocol.ts` the bodies read, * `canonicalMetaType`, stays there, where code that did not move reads it too, @@ -55,6 +56,7 @@ import { canonicalMetaType } from './protocol.js'; import type { ItemLock } from './item-lock.js'; import { isOriginGatedType } from './packaged-base-regime.js'; import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { servesSealedOverrideRow } from './protocol-package-bindings.js'; import { isCodeArtifactBody } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; @@ -74,6 +76,7 @@ import type { resolveLockState } from '@objectstack/spec/kernel'; * here too, and naming a member the class does not have is a compile error. */ export interface LockStateHost { + readonly engine: ObjectStackProtocolImplementation['engine']; declinesStoredRow: ObjectStackProtocolImplementation['declinesStoredRow']; isDeclaredCodeDatasource: ObjectStackProtocolImplementation['isDeclaredCodeDatasource']; isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; @@ -143,14 +146,15 @@ export function isStoredFlowEntryOfShippedName( /** The body of `ObjectStackProtocolImplementation#declinesStoredRow`; the method's docblock there is its contract. */ export function declinesStoredRow( - this: Pick, + this: Pick, type: string, name: unknown, row?: ServedOverrideRow): boolean { + const installed = installedManifestsOfEngine(this.engine); if (this.isShippedFlowName(type, name)) { // [#28412, ADR-0146 D9 item 1] …except the one row that IS the // packaged flow now: the shipping package's declared override, judged // on the row's own `package_id` and body by the one served-override // predicate. Every other stored row of the name stays declined. - return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string })); + return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string }), installed); } if (typeof name !== 'string' || name === '') return false; if (this.isDeclaredCodeDatasource(type, name)) return true; @@ -165,7 +169,7 @@ export function declinesStoredRow( // row, so the layered read's `effective` and a by-name read answered by // the metadata service see it as the registry's readers do. return !((servesSealedOverrideRow(type) || canonicalMetaType(type) === 'action') - && isServedOverrideRowOf(row, this.packagedArtifactOwner({ type, name }))); + && isServedOverrideRowOf(row, this.packagedArtifactOwner({ type, name }), installed)); } /** The body of `ObjectStackProtocolImplementation#isUnservedSealedOverlay`; the method's docblock there is its contract. */ diff --git a/packages/metadata-protocol/src/protocol-meta-layered-reads.ts b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts index 5c24659a7e6..3076466658e 100644 --- a/packages/metadata-protocol/src/protocol-meta-layered-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts @@ -58,6 +58,7 @@ import { rowMatchesOverlayWhere } from './protocol-overlay-merge.js'; import { boundPackageOf } from './protocol-package-bindings.js'; import type { RowlessExpandedView } from './protocol-stored-overlay-types.js'; import { type ServedOverrideRow, isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { packagelessAddressAmbiguityRefusal } from './sys-metadata-repository.js'; /** The body of `ObjectStackProtocolImplementation#packagelessLayeredReadRefusal`; the method's docblock there is its contract. */ @@ -399,18 +400,19 @@ export async function getMetaItemLayered(host: Pick boundPackageOf(row.package_id); + const installed = installedManifestsOfEngine(host.engine); const orphan = rows .filter((row) => rowMatchesOverlayWhere(row, memberWhere)) .filter((row) => { const owner = boundTo(row); if (owner === undefined || owner === base) return false; - if (isServedOverrideRowOf(row, base)) return false; - const pair = pairOf(base); + if (isServedOverrideRowOf(row, base, installed)) return false; + const pair = pairOf(base, installed); if (pair.ok && pair.value === owner) return true; return host.resolveWritePackageScope(owner)?.dependencies.includes(base) === true; }) .sort((a, b) => { - const pair = pairOf(base); + const pair = pairOf(base, installed); const rank = (row: StoredOverlayRow) => (pair.ok && boundTo(row) === pair.value ? 0 : 1); return rank(a) - rank(b) || String(boundTo(a)).localeCompare(String(boundTo(b))); })[0]; diff --git a/packages/metadata-protocol/src/protocol-meta-list-reads.ts b/packages/metadata-protocol/src/protocol-meta-list-reads.ts index 76155c26f82..22974ca3404 100644 --- a/packages/metadata-protocol/src/protocol-meta-list-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-list-reads.ts @@ -48,6 +48,7 @@ import { import { storedEntryRow } from './protocol-stored-overlay-types.js'; import { viewIdentityPatch } from './protocol-view-normalization.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** The body of `ObjectStackProtocolImplementation#getMetaItems`; the method's docblock there is its contract. */ export async function getMetaItems(host: Pick, request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }) { @@ -82,6 +83,9 @@ export async function readFlattenedMetaItems(host: Pick row?.package_id === pairOfScope.value) : []; @@ -267,7 +271,7 @@ export async function readFlattenedMetaItems(host: Pick typeof row?.name === 'string' && isServedOverrideRowOf(row, pkg)) + .filter((row) => typeof row?.name === 'string' && isServedOverrideRowOf(row, pkg, installed)) .map((row) => row.name as string); if (overridden.length > 0) names = new Set([...names, ...overridden]); } @@ -527,7 +531,7 @@ export async function readFlattenedMetaItems(host: Pick record?.package_id === pairOfDraftScope.value) : []; @@ -562,7 +566,7 @@ export async function readFlattenedMetaItems(host: Pick { if (data && typeof data === 'object') (data as any)._draft = true; return data; - }, unseated, { servedOverride }); + }, unseated, { servedOverride, installed }); } } catch (error) { // [#5532] Same rule as the active-overlay read above. Serving @@ -671,7 +675,7 @@ export async function readFlattenedMetaItems(host: Pick> = base === null ? [{ packageId: null }] @@ -126,6 +111,39 @@ export function servedOverlayRowCandidates(address: { places.map((place) => ({ spelling, ...place }))); } +/** + * The address {@link servedOverlayRowCandidates} orders the rows of. A read + * that may serve a declared override names the registry it serves + * (`installed`), because the address's pair is the pair of the App that + * carries its package (`pairOf`); a read that never serves one names neither, + * so `servedOverride` without `installed` does not compile. + */ +export type ServedOverlayAddress = + | { + /** + * [#23715] `null` addresses the package-unbound row alone: the strict + * draft read naming no package, at the draft its `version` names + * ({@link ObjectStackProtocolImplementation.strictDraftReadAddress}). + */ + readonly packageId: string | null | undefined; + readonly servedOverride?: undefined; + readonly installed?: undefined; + } + | { + readonly packageId: string | null | undefined; + /** + * [#28409, ADR-0146 D8 item 2] Whether the address's declared override + * is a candidate: the reads that SERVE a package's name (the active + * by-name read and its draft preview, the layered read, the list's merges) + * pass `true`. A read that answers a write's address (the strict draft + * read, the view container expansion's package order) passes nothing: a + * save naming the base writes the base's own partition, never its pair's. + */ + readonly servedOverride: boolean | undefined; + /** The installed manifests of the registry the read serves, read for the address's pair. */ + readonly installed: InstalledManifestLookup; + }; + /** * [#24371] Whether a stored row read by * {@link ObjectStackProtocolImplementation.readItemActiveRows} is one the @@ -261,16 +279,24 @@ export function mergePackageAwareOverlay( records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, unseated?: WeakSet, - options: { - /** - * [#28409, ADR-0146 D8 item 2] Serve each package's declared override - * over its slot ({@link servedOverlayRowCandidates}' `servedOverride`): - * the effective list's merges pass `true`; the per-package layer - * ({@link ObjectStackProtocolImplementation.getMetaItemsPerPackage}) - * and the MetadataService merge pass nothing. - */ - readonly servedOverride?: boolean; - } = {}, + options: + | { readonly servedOverride?: undefined; readonly installed?: undefined } + | { + /** + * [#28409, ADR-0146 D8 item 2] Serve each package's declared override + * over its slot ({@link servedOverlayRowCandidates}' `servedOverride`): + * the effective list's merges pass `true`; the per-package layer + * ({@link ObjectStackProtocolImplementation.getMetaItemsPerPackage}) + * and the MetadataService merge pass nothing. + */ + readonly servedOverride: boolean | undefined; + /** + * The installed manifests of the registry the list serves: the + * declared override of a package's slot is its App's pair's + * ({@link servedOverlayRowCandidates}). + */ + readonly installed: InstalledManifestLookup; + } = {}, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. // `stored` marks a contribution that is a stored row the caller read; @@ -298,7 +324,9 @@ export function mergePackageAwareOverlay( list: readonly Contribution[], real: string | undefined, ): { item: any; overrideOf?: string } | undefined => { - for (const candidate of servedOverlayRowCandidates({ packageId: real, servedOverride: options.servedOverride })) { + for (const candidate of servedOverlayRowCandidates(options.installed !== undefined + ? { packageId: real, servedOverride: options.servedOverride, installed: options.installed } + : { packageId: real })) { const hit = list.find((c) => c.stored !== undefined && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling && (candidate.packageId === undefined || (c.pkg ?? null) === candidate.packageId) @@ -308,6 +336,7 @@ export function mergePackageAwareOverlay( // and its body, never on a stamp the body carries. && (candidate.overrideOf === undefined || isServedOverrideRowOf( { type: c.stored.type, package_id: c.pkg, metadata: c.item }, candidate.overrideOf, + options.installed!, ))); if (hit) return candidate.overrideOf === undefined ? { item: hit.item } : { item: hit.item, overrideOf: candidate.overrideOf }; } diff --git a/packages/metadata-protocol/src/protocol-package-bindings.ts b/packages/metadata-protocol/src/protocol-package-bindings.ts index e28599b271e..721a883e955 100644 --- a/packages/metadata-protocol/src/protocol-package-bindings.ts +++ b/packages/metadata-protocol/src/protocol-package-bindings.ts @@ -15,6 +15,7 @@ import { } from './item-lock.js'; import { itemDiscriminator, + type InstalledManifestLookup, } from '@objectstack/metadata-core'; import { isServedOverrideRowOf } from './served-override.js'; import { canonicalMetaType } from './protocol-meta-type-schema.js'; @@ -269,6 +270,7 @@ function perPackageLayerEnvelope( itemPackageId: string | undefined, artifact: unknown, rowsOfName: readonly any[], + installed: InstalledManifestLookup, ): unknown { if (artifact === undefined || artifact === null || typeof artifact !== 'object') return artifact; const own = boundPackageOf(itemPackageId); @@ -277,7 +279,7 @@ function perPackageLayerEnvelope( if (lender === own) return artifact; const member = bundleMemberOf(type, item); const declared = rowsOfName.some((row) => { - if (row?.package_id !== own || !isServedOverrideRowOf(row, lender)) return false; + if (row?.package_id !== own || !isServedOverrideRowOf(row, lender, installed)) return false; try { return bundleMemberOf(type, storedRowDocument(row)) === member; } catch { diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index e1d778dff0c..402367e86d7 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -58,7 +58,8 @@ import { } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec'; -import { ITEM_KEY_DISCRIMINATORS, isPairOf, itemDiscriminator, pairOf } from '@objectstack/metadata-core'; +import { ITEM_KEY_DISCRIMINATORS, isPairOf, itemDiscriminator, pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -68,6 +69,7 @@ import { ITEM_KEY_DISCRIMINATORS, isPairOf, itemDiscriminator, pairOf } from '@o * here too, and naming a member the class does not have is a compile error. */ export interface PackagedBaseRefusalHost { + readonly engine: ObjectStackProtocolImplementation['engine']; isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; @@ -78,7 +80,7 @@ export interface PackagedBaseRefusalHost { /** The body of `ObjectStackProtocolImplementation#refusePackagedBaseOverride`; the method's docblock there is its contract. */ export function refusePackagedBaseOverride( - this: Pick, + this: Pick, request: { type: string; name: string; packageId?: string | null }, ): void { if (this.isSealedManagedItem(request.type, request.name)) { @@ -163,11 +165,12 @@ export function refusePackagedBaseOverride( // [#28412] A Regime C type the declared override opens (`flow`) names // the declaration in place of its clone path, keeping its switch. const owner = this.packagedArtifactOwner(request); - const declare = sealedDeclareOrRenameClause(request.type, owner, request.packageId); + const installed = installedManifestsOfEngine(this.engine); + const declare = sealedDeclareOrRenameClause(request.type, owner, request.packageId, installed); const err = new Error( (declare === undefined ? undefined : regimeCDeclaredSaveSentence(request.type, request.name, declare)) ?? managedItemSealedSentence(request.type, request.name, 'save') - + sealedDeclareOrRenameTail(request.type, owner, request.packageId), + + sealedDeclareOrRenameTail(request.type, owner, request.packageId, installed), ); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; @@ -189,10 +192,15 @@ export function refusePackagedBaseOverride( * when the shipping package has no pair id. [#28412] `flow` is open: its * sealed sentence names the declaration too, and so, [#30065], does `action`'s. */ -function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerPackageId: unknown): string { +function sealedDeclareOrRenameTail( + type: string, + basePackageId: unknown, + writerPackageId: unknown, + installed: InstalledManifestLookup, +): string { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return ''; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installed); if (!pair.ok) return ''; const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined; if (writer !== undefined && writer !== pair.value) { @@ -209,10 +217,15 @@ function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerP * may declare from the pair or rename; any other named package may only * rename. `undefined` exactly where the tail is empty. */ -function sealedDeclareOrRenameClause(type: string, basePackageId: unknown, writerPackageId: unknown): string | undefined { +function sealedDeclareOrRenameClause( + type: string, + basePackageId: unknown, + writerPackageId: unknown, + installed: InstalledManifestLookup, +): string | undefined { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return undefined; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installed); if (!pair.ok) return undefined; const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined; if (writer !== undefined && writer !== pair.value) { @@ -414,7 +427,7 @@ export function overlayPairFixOf( host: { readonly engine: ObjectStackProtocolImplementation['engine'] }, base: string, ): OverlayPairFix | null { - const pairId = pairOf(base); + const pairId = pairOf(base, installedManifestsOfEngine(host.engine)); if (!pairId.ok) return null; // no pair to name (the census's `no_pair`) const pair = pairId.value; const registry = (host.engine as { registry?: PairRegistry } | undefined)?.registry; @@ -674,12 +687,17 @@ function isOpenOverrideType(singular: string): boolean { * verdict. [#30065] `action` is open, so a pair's removal of its declared * action override is admitted and the base's action is served again. */ -export function isPairRowRemoval(type: string, basePackageId: unknown, packageId: unknown): boolean { +export function isPairRowRemoval( + type: string, + basePackageId: unknown, + packageId: unknown, + installed: InstalledManifestLookup, +): boolean { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (!isOpenOverrideType(singular)) return false; if (typeof basePackageId !== 'string') return false; if (typeof packageId !== 'string' || packageId.length === 0) return false; - const pair = pairOf(basePackageId); + const pair = pairOf(basePackageId, installed); return pair.ok && pair.value === packageId; } @@ -825,6 +843,13 @@ export interface OverrideWriteClosure { * answering none of that name, the refusal reads as before. */ readonly baseItemsOf?: (basePackageId: string) => readonly unknown[]; + /** + * The installed manifests the door reads (`installedManifestsOf` over its + * registry, or over the manifests an offline door holds): who may declare + * an override of a base is the pair of the App that carries the base + * (`pairOf`, `isPairOf` in `@objectstack/metadata-core`). + */ + readonly installed: InstalledManifestLookup; } /** [#28834] The item door an `object_extension` is saved through (#28574), as the refusals name it. */ @@ -1071,8 +1096,8 @@ export function judgeOverrideWrite( + 'override is refused (ADR-0146 D9). Remove `overrides` and rename the item.'); } const base = declaration.base; - const pair = base === undefined ? undefined : pairOf(base); - if (base === undefined || pair === undefined || !pair.ok || !isPairOf(packageId, base)) { + const pair = base === undefined ? undefined : pairOf(base, closure.installed); + if (base === undefined || pair === undefined || !pair.ok || !isPairOf(packageId, base, closure.installed)) { const who = packageId === undefined ? 'a row bound to no package' : `package '${packageId}'`; const only = pair?.ok ? `only '${pair.value}' may declare an override of '${base}'` @@ -1211,7 +1236,7 @@ export function overrideWriteRefusalError( * write passes none. */ export function overrideWriteVerdict( - this: Pick, + this: Pick, request: OverrideWriteSubject, writtenScope?: WrittenPackageScope, ): OverrideWriteVerdict { @@ -1262,7 +1287,9 @@ export function overrideWriteVerdict( const baseItemsOf = (basePackageId: string): unknown[] => this.shippedArtifactsOf(request.type, request.name, undefined, member) .filter((a) => overrideWriteMemberOf(request.type, a) === member && (a as { _packageId?: unknown } | null | undefined)?._packageId === basePackageId); - return judgeOverrideWrite(request, { dependencies, shippers, baseItemOf, baseItemsOf }); + return judgeOverrideWrite(request, { + dependencies, shippers, baseItemOf, baseItemsOf, installed: installedManifestsOfEngine(this.engine), + }); } diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index 13a692fd2ae..84471a65a59 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -136,7 +136,7 @@ import { OVERRIDABLE_METADATA_TYPES, customizationBasePackageId, } from '@objectstack/spec/kernel'; -import { pairOf } from '@objectstack/metadata-core'; +import { pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; @@ -150,6 +150,7 @@ import { } from './protocol-packaged-base-refusal.js'; import { readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** The class members this module reads, typed as the class declares them. */ export interface PackagelessOverlayMigrationHost { @@ -377,10 +378,11 @@ export function classifyPackagelessRow( shippers: readonly string[], body: Record | undefined, shadowOf: (pair: string) => unknown, + installed: InstalledManifestLookup, ): Pick { if (shippers.length > 1) return { disposition: 'multi_shipped' }; const base = shippers[0]!; - const pairId = pairOf(base); + const pairId = pairOf(base, installed); if (!pairId.ok) return { base, disposition: 'no_pair' }; const pair = pairId.value; if (body !== undefined && overrideDeclarationRefusedByShape(type, body)) return { base, pair, disposition: 'cannot_declare' }; @@ -391,7 +393,7 @@ export function classifyPackagelessRow( } const shadow = shadowOf(pair); if (shadow !== undefined) { - return { base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base) }; + return { base, pair, disposition: 'shadowed', shadowDeclares: isServedOverrideRowOf(shadow, base, installed) }; } return { base, pair, disposition: 'movable' }; } @@ -405,7 +407,7 @@ export function classifyPackagelessRow( * classifies it; an unprovisioned table answers an empty census. */ export async function listPackagelessOverlays( - this: Pick, + this: Pick, ): Promise { const rows = await overlayRows.call(this); // What every pair holds, per item identity and state. @@ -456,7 +458,7 @@ export async function listPackagelessOverlays( const body = bodyOf(row); // A row bound to the base names its base: the binding is its one shipper. const classified = classifyPackagelessRow(type, binding !== undefined ? [binding] : shippers, body, - (pair) => shadowOf(pair, type, name, member, state)); + (pair) => shadowOf(pair, type, name, member, state), installedManifestsOfEngine(this.engine)); const addedMember = classified.disposition === 'movable' && classified.base !== undefined ? addedMemberOf(this, type, name, classified.base, body) : undefined; @@ -473,7 +475,7 @@ export async function listPackagelessOverlays( for (const row of await environmentRowsOfTypes.call(this, OVERRIDABLE_TYPE_SPELLINGS)) { if (isUnbound(row)) continue; const base = customizationBasePackageId(row.package_id); - if (base === null || !isServedOverrideRowOf(row, base)) continue; + if (base === null || !isServedOverrideRowOf(row, base, installedManifestsOfEngine(this.engine))) continue; const basedOn = (bodyOf(row) as { overrides?: { basedOn?: unknown } } | undefined)?.overrides?.basedOn; if (typeof basedOn === 'string' && basedOn !== '') continue; const member = memberOf(row); @@ -611,7 +613,7 @@ export async function migratePackagelessOverlays( // Step 3: read back — the pair's row must be the served override, // or, for an added member, that member's own row declaring nothing. const written = await storedPairRow(this, row, row.pair); - if (addedMember === undefined ? !isServedOverrideRowOf(written, row.base) : !isOwnMemberRow(written)) { + if (addedMember === undefined ? !isServedOverrideRowOf(written, row.base, installedManifestsOfEngine(this.engine)) : !isOwnMemberRow(written)) { const kept = bound ? `the row bound to '${row.base}' is kept` : 'the unbound row is kept'; fail(addedMember === undefined ? `The row saved into '${row.pair}' does not read back as the declared override of '${row.base}'; ${kept}.` @@ -674,7 +676,7 @@ export async function migratePackagelessOverlays( } as never); const written = await storedPairRow(this, decl, decl.pair); const recorded = (bodyOf(written) as { overrides?: { basedOn?: unknown } } | undefined)?.overrides?.basedOn; - if (!isServedOverrideRowOf(written, decl.base) || recorded !== basedOn) { + if (!isServedOverrideRowOf(written, decl.base, installedManifestsOfEngine(this.engine)) || recorded !== basedOn) { fail(`The row of '${decl.pair}' does not read back with basedOn '${basedOn}'.`); continue; } @@ -796,7 +798,7 @@ export async function packagelessCloseSubject( const classified = classifyPackagelessRow(type, shippers, body, (p) => (stored === undefined ? undefined : ofItem.find((row) => row.package_id === p && stateOf(row) === request.state) - ?? ofItem.find((row) => row.package_id === p))); + ?? ofItem.find((row) => row.package_id === p)), installedManifestsOfEngine(this.engine)); if (classified.disposition !== 'movable') return null; // Step 2's gate: the declared body, into the pair. diff --git a/packages/metadata-protocol/src/protocol-registry-hydration.ts b/packages/metadata-protocol/src/protocol-registry-hydration.ts index 922d07517f9..0a3f8f4e100 100644 --- a/packages/metadata-protocol/src/protocol-registry-hydration.ts +++ b/packages/metadata-protocol/src/protocol-registry-hydration.ts @@ -31,7 +31,7 @@ import { overlayMemberWhere } from './migrations/overlay-index.js'; import { storedRowDocument } from './item-lock.js'; -import { itemDiscriminator } from '@objectstack/metadata-core'; +import { installedManifestsOf, itemDiscriminator } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec/ui'; import { bindStoredRow } from './stored-row-binding.js'; @@ -524,7 +524,7 @@ export class ProtocolRegistryHydrationFacet { ? registry.getItem(type, String((data as any).name)) : undefined; const owner = host.packagedArtifactOwner({ type, name: String((data as any).name) }); - if (!incomingTakesStoredFlowSlot(held, registered, owner)) return true; + if (!incomingTakesStoredFlowSlot(held, registered, owner, installedManifestsOf(registry))) return true; } registry.registerItem(type, registered, 'name' as any); await host.hydrateExpandedViewItems(type, data, options); diff --git a/packages/metadata-protocol/src/protocol-served-overrides.ts b/packages/metadata-protocol/src/protocol-served-overrides.ts index 50fb59817f2..0d548f4c8dd 100644 --- a/packages/metadata-protocol/src/protocol-served-overrides.ts +++ b/packages/metadata-protocol/src/protocol-served-overrides.ts @@ -56,9 +56,11 @@ import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; import { isServedOverrideRowOf } from './served-override.js'; +import { installedManifestsOfEngine } from './installed-manifests.js'; /** The class members {@link listServedOverrides} reads, typed as the class declares them. */ export interface ServedOverridesHost { + readonly engine: ObjectStackProtocolImplementation['engine']; readonly findEnvironmentRows: ObjectStackProtocolImplementation['findEnvironmentRows']; readonly declinesStoredRow: ObjectStackProtocolImplementation['declinesStoredRow']; readonly rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned']; @@ -91,11 +93,12 @@ export interface ServedOverrideEntry { * An unprovisioned table answers `[]`. */ export async function listServedOverrides( - this: Pick, + this: Pick, request: { packageId: string }, ): Promise { const base = request.packageId; - const pair = pairOf(base); + const installed = installedManifestsOfEngine(this.engine); + const pair = pairOf(base, installed); if (!pair.ok) return []; let rows: any[] = []; try { @@ -112,7 +115,7 @@ export async function listServedOverrides( const entries = new Map(); for (const row of rows ?? []) { if (typeof row?.name !== 'string' || row.name === '') continue; - if (!isServedOverrideRowOf(row, base)) continue; + if (!isServedOverrideRowOf(row, base, installed)) continue; const type = PLURAL_TO_SINGULAR[row.type] ?? row.type; if (this.declinesStoredRow(type, row.name, row)) continue; const member = typeof row.bundle_member === 'string' && row.bundle_member !== '' ? row.bundle_member : undefined; diff --git a/packages/metadata-protocol/src/protocol-write-gates.ts b/packages/metadata-protocol/src/protocol-write-gates.ts index 9244880c1e1..24b162223d6 100644 --- a/packages/metadata-protocol/src/protocol-write-gates.ts +++ b/packages/metadata-protocol/src/protocol-write-gates.ts @@ -38,6 +38,7 @@ import { envWritableMetadataTypes } from './sys-metadata-repository.js'; import { managedItemSealedSentence } from './packaged-base-regime.js'; import type { StoredOverlayRow } from './item-lock.js'; import { itemDiscriminator, pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOfEngine } from './installed-manifests.js'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { baseAddressServesMember, readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; @@ -637,10 +638,11 @@ export class ProtocolWriteGatesFacet { * `item` is. The one member-grain answer the env-wide list, the list * scoped to `base` and the by-name reads give. */ - static baseServesBundleMember(host: Pick, type: string, name: string, base: string, item: unknown, shipping: ShippingPackages): boolean { + static baseServesBundleMember(host: Pick, type: string, name: string, base: string, item: unknown, shipping: ShippingPackages): boolean { const member = itemDiscriminator(canonicalMetaType(type), item); const baseShipsMember = shippingPackageIds(host.shippedArtifactsOf(type, name, shipping, member)).has(base); - return baseAddressServesMember(base, boundPackageOf((item as { _packageId?: unknown } | null | undefined)?._packageId), baseShipsMember); + return baseAddressServesMember(base, boundPackageOf((item as { _packageId?: unknown } | null | undefined)?._packageId), baseShipsMember, + installedManifestsOfEngine(host.engine)); } /** @@ -649,11 +651,11 @@ export class ProtocolWriteGatesFacet { * pair can add on `base`'s address: `base` ships the name and not that * member. Else `undefined`, and the by-name read serves as before. */ - static pairOfAddedMember(host: Pick, type: string, name: string, base: string, member: string): string | undefined { + static pairOfAddedMember(host: Pick, type: string, name: string, base: string, member: string): string | undefined { const shipping = host.shippingPackagesOf(type); if (!host.shipsBundleName(type, name, base, shipping)) return undefined; if (shippingPackageIds(host.shippedArtifactsOf(type, name, shipping, member)).has(base)) return undefined; - const pair = pairOf(base); + const pair = pairOf(base, installedManifestsOfEngine(host.engine)); return pair.ok ? pair.value : undefined; } diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index 301a103cf86..f491e5736bb 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -27,7 +27,7 @@ * this package. */ import { afterEach, describe, expect, it } from 'vitest'; -import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, installedManifestsOf } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY, OVERRIDE_TYPE_CLASSIFICATION } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { judgeOverrideWrite } from './protocol-packaged-base-refusal.js'; @@ -999,7 +999,7 @@ describe('[#30062] each excluded type\'s refusal names a remedy that works for i const refusalOf = (type: string): string => { const verdict = judgeOverrideWrite( { type, name: 'their_item', packageId: PAIR_ID, item: { name: 'their_item', overrides: { package: BASE_ID, basedOn: '1.0.0' } } }, - { dependencies: [BASE_ID], shippers: [BASE_ID] }, + { dependencies: [BASE_ID], shippers: [BASE_ID], installed: installedManifestsOf(undefined) }, ); expect(verdict, type).toMatchObject({ verdict: 'refuse', rule: 'declared_type_not_overridable' }); return (verdict as { message: string }).message; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b6a184beb8e..2d260b000d0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3824,7 +3824,9 @@ export class ObjectStackProtocolImplementation implements * table answers `[]`. */ async listServedOverrides(request: { packageId: string }): Promise { + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), declinesStoredRow: (...a) => this.declinesStoredRow(...a), rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a), @@ -3849,7 +3851,9 @@ export class ObjectStackProtocolImplementation implements * table answers an empty census. */ async listPackagelessOverlays(): Promise { + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, overlayTypes: ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES, findEnvironmentRows: (...a) => this.findEnvironmentRows(...a), rethrowUnlessMetadataStoreUnprovisioned: (...a) => this.rethrowUnlessMetadataStoreUnprovisioned(...a), @@ -4192,7 +4196,9 @@ export class ObjectStackProtocolImplementation implements servedOverride?: boolean; }, activeRows?: readonly StoredOverlayRow[]): Promise<{ row: any; overrideOf?: string } | undefined> { // [#25467 cut 35] The body moved to `protocol-environment-rows.ts` (see `EnvironmentRowsHost`). + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, findEnvironmentRow: (...a) => this.findEnvironmentRow(...a), }; return findServedOverlayRow.call(host, args, activeRows); @@ -6925,7 +6931,9 @@ export class ObjectStackProtocolImplementation implements */ declinesStoredRow(type: string, name: unknown, row?: ServedOverrideRow): boolean { // [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`). + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, isDeclaredCodeDatasource: (...a) => this.isDeclaredCodeDatasource(...a), isShippedFlowName: (...a) => this.isShippedFlowName(...a), isUnservedSealedOverlay: (...a) => this.isUnservedSealedOverlay(...a), @@ -7204,7 +7212,9 @@ export class ObjectStackProtocolImplementation implements request: { type: string; name: string; packageId?: string | null }, ): void { // [#25467 cut 13] The body moved to `protocol-packaged-base-refusal.ts` (see `PackagedBaseRefusalHost`). + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, isSealedManagedItem: (...a) => this.isSealedManagedItem(...a), isWritablePackage: (...a) => this.isWritablePackage(...a), packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a), @@ -7248,7 +7258,9 @@ export class ObjectStackProtocolImplementation implements * with the closure a pair not yet ensured will have (`writtenScope`). */ private overrideWriteVerdict(request: OverrideWriteSubject, writtenScope?: WrittenPackageScope): OverrideWriteVerdict { + const readEngine = () => this.engine; const host: ThisParameterType = { + get engine() { return readEngine(); }, isWritablePackage: (...a) => this.isWritablePackage(...a), resolveWritePackageScope: (...a) => this.resolveWritePackageScope(...a), shippedArtifactsOf: (...a) => this.shippedArtifactsOf(...a), diff --git a/packages/metadata-protocol/src/served-override.test.ts b/packages/metadata-protocol/src/served-override.test.ts index e0c7b8f2350..fb301cc6354 100644 --- a/packages/metadata-protocol/src/served-override.test.ts +++ b/packages/metadata-protocol/src/served-override.test.ts @@ -10,6 +10,10 @@ */ import { describe, expect, it } from 'vitest'; import { isServedOverrideRowOf, servedOverrideBaseOf } from './served-override.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const BASE = 'com.acme.crm'; const PAIR = 'com.acme.crm.custom'; @@ -29,56 +33,56 @@ const row = (over: Record = {}) => ({ describe('[#28409] isServedOverrideRowOf', () => { it('a row of the pair, of an overridable type, declaring the base: the served override', () => { - expect(isServedOverrideRowOf(row(), BASE)).toBe(true); + expect(isServedOverrideRowOf(row(), BASE, NOTHING_INSTALLED)).toBe(true); // The body as stored, a JSON string, or already parsed. - expect(isServedOverrideRowOf(row({ metadata: body() }), BASE)).toBe(true); + expect(isServedOverrideRowOf(row({ metadata: body() }), BASE, NOTHING_INSTALLED)).toBe(true); // The type folds to its canonical singular, as the reads fold it. - expect(isServedOverrideRowOf(row({ type: 'views' }), BASE)).toBe(true); + expect(isServedOverrideRowOf(row({ type: 'views' }), BASE, NOTHING_INSTALLED)).toBe(true); // `basedOn` is recorded and never judged (ADR-0146 D2). - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE } }) }), BASE)).toBe(true); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE, basedOn: '0.0.1' } }) }), BASE)).toBe(true); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE } }) }), BASE, NOTHING_INSTALLED)).toBe(true); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: BASE, basedOn: '0.0.1' } }) }), BASE, NOTHING_INSTALLED)).toBe(true); }); it('bound by the row\'s own `package_id`: another package\'s declaration is ignored (ruling A, #28390)', () => { - expect(isServedOverrideRowOf(row({ package_id: 'com.partner.ext' }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: BASE }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: 'com.other.custom' }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: 'com.partner.ext' }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: BASE }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: 'com.other.custom' }), BASE, NOTHING_INSTALLED)).toBe(false); // The same row is the pair of one base only. - expect(isServedOverrideRowOf(row(), 'com.acme')).toBe(false); + expect(isServedOverrideRowOf(row(), 'com.acme', NOTHING_INSTALLED)).toBe(false); }); it('never a body stamp: an unbound row whose body names the pair binds nothing', () => { const stamped = JSON.stringify(body({ _packageId: PAIR })); - expect(isServedOverrideRowOf(row({ package_id: null, metadata: stamped }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: undefined, metadata: stamped }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ package_id: '', metadata: stamped }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: null, metadata: stamped }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: undefined, metadata: stamped }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ package_id: '', metadata: stamped }), BASE, NOTHING_INSTALLED)).toBe(false); }); it('the declaration must name the base', () => { - expect(isServedOverrideRowOf(row({ metadata: JSON.stringify(body({ overrides: undefined })) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: 'com.other' } }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: PAIR } }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: BASE }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: [BASE] }) }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: body({ overrides: null }) }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: JSON.stringify(body({ overrides: undefined })) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: 'com.other' } }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: { package: PAIR } }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: BASE }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: [BASE] }) }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: body({ overrides: null }) }), BASE, NOTHING_INSTALLED)).toBe(false); }); it('a type a declaration never overrides: extended (object) or one name per deployment', () => { for (const type of ['object', 'objects', 'permission_set', 'position', 'capability']) { - expect(isServedOverrideRowOf(row({ type }), BASE), type).toBe(false); + expect(isServedOverrideRowOf(row({ type }), BASE, NOTHING_INSTALLED), type).toBe(false); } - expect(isServedOverrideRowOf(row({ type: undefined }), BASE)).toBe(false); + expect(isServedOverrideRowOf(row({ type: undefined }), BASE, NOTHING_INSTALLED)).toBe(false); }); it('total: false for any input it cannot read as a declaring row', () => { - expect(isServedOverrideRowOf(null, BASE)).toBe(false); - expect(isServedOverrideRowOf(undefined, BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: '{not json' }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: '[]' }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row({ metadata: null }), BASE)).toBe(false); - expect(isServedOverrideRowOf(row(), '')).toBe(false); - expect(isServedOverrideRowOf(row(), undefined)).toBe(false); - expect(isServedOverrideRowOf(row(), 42)).toBe(false); + expect(isServedOverrideRowOf(null, BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(undefined, BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: '{not json' }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: '[]' }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row({ metadata: null }), BASE, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row(), '', NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row(), undefined, NOTHING_INSTALLED)).toBe(false); + expect(isServedOverrideRowOf(row(), 42, NOTHING_INSTALLED)).toBe(false); }); }); diff --git a/packages/metadata-protocol/src/served-override.ts b/packages/metadata-protocol/src/served-override.ts index e4904e8a57b..388a3969b4d 100644 --- a/packages/metadata-protocol/src/served-override.ts +++ b/packages/metadata-protocol/src/served-override.ts @@ -47,13 +47,14 @@ * before they ask this predicate (`declinesStoredRow`). Arming a flow override * is N-11's. * - * Pure, synchronous and total: no I/O, no registry read, and `false` for any - * input it cannot read as a declaring row. Whether the pair package is + * Pure, synchronous and total: no I/O, and `false` for any input it cannot + * read as a declaring row. Which App carries X is read through the caller's + * `installed` lookup (the registry it serves); whether the pair package is * INSTALLED is not its question. */ import { isOverridableMetadataType } from '@objectstack/spec/kernel'; -import { isPairOf } from '@objectstack/metadata-core'; +import { isPairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; @@ -84,10 +85,14 @@ export interface ServedOverrideRow { * | `package_id: null`, body `_packageId: 'hotcrm.custom'`, `overrides.package: 'hotcrm'` | `hotcrm` | `false` — a body stamp binds nothing | * | `object`, `package_id: 'hotcrm.custom'`, `overrides.package: 'hotcrm'` | `hotcrm` | `false` — extended, never replaced | */ -export function isServedOverrideRowOf(row: ServedOverrideRow | null | undefined, basePackageId: unknown): boolean { +export function isServedOverrideRowOf( + row: ServedOverrideRow | null | undefined, + basePackageId: unknown, + installed: InstalledManifestLookup, +): boolean { if (row === null || row === undefined || typeof row !== 'object') return false; if (typeof basePackageId !== 'string' || basePackageId === '') return false; - if (!isPairOf(row.package_id, basePackageId)) return false; + if (!isPairOf(row.package_id, basePackageId, installed)) return false; if (typeof row.type !== 'string' || !isOverridableMetadataType(PLURAL_TO_SINGULAR[row.type] ?? row.type)) { return false; } diff --git a/packages/metadata-protocol/src/stored-flow-slot.test.ts b/packages/metadata-protocol/src/stored-flow-slot.test.ts index 50c953ef23d..cb77e935b00 100644 --- a/packages/metadata-protocol/src/stored-flow-slot.test.ts +++ b/packages/metadata-protocol/src/stored-flow-slot.test.ts @@ -12,6 +12,10 @@ import { describe, expect, it } from 'vitest'; import { bindStoredRow } from './stored-row-binding.js'; import { incomingTakesStoredFlowSlot, shadowedStoredRowsOf } from './stored-flow-slot.js'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const BASE = 'com.acme.crm'; const PAIR = `${BASE}.custom`; @@ -26,58 +30,58 @@ describe('[#28717] the declared override keeps the bare slot of its shipped flow it('held override, incoming legacy row: the override keeps the slot and carries the legacy row', () => { const held = override(); const incoming = legacy(); - expect(incomingTakesStoredFlowSlot(held, incoming, BASE)).toBe(false); + expect(incomingTakesStoredFlowSlot(held, incoming, BASE, NOTHING_INSTALLED)).toBe(false); expect(labels(shadowedStoredRowsOf(held))).toEqual(['LEGACY']); }); it('held legacy row, incoming override: the override takes the slot and carries the legacy row', () => { const held = legacy(); const incoming = override(); - expect(incomingTakesStoredFlowSlot(held, incoming, BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(held, incoming, BASE, NOTHING_INSTALLED)).toBe(true); expect(labels(shadowedStoredRowsOf(incoming))).toEqual(['LEGACY']); }); it('the legacy row read again is carried once, as its latest body', () => { const held = override(); - incomingTakesStoredFlowSlot(held, legacy(), BASE); + incomingTakesStoredFlowSlot(held, legacy(), BASE, NOTHING_INSTALLED); const again = bindStoredRow(body('LEGACY 2'), null); - expect(incomingTakesStoredFlowSlot(held, again, BASE)).toBe(false); + expect(incomingTakesStoredFlowSlot(held, again, BASE, NOTHING_INSTALLED)).toBe(false); expect(labels(shadowedStoredRowsOf(held))).toEqual(['LEGACY 2']); }); it('the override read again replaces itself and keeps what it shadowed, declared or not', () => { const held = override(); - incomingTakesStoredFlowSlot(held, legacy(), BASE); + incomingTakesStoredFlowSlot(held, legacy(), BASE, NOTHING_INSTALLED); const resaved = bindStoredRow(body('OVERRIDE, UNDECLARED'), PAIR); - expect(incomingTakesStoredFlowSlot(held, resaved, BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(held, resaved, BASE, NOTHING_INSTALLED)).toBe(true); expect(labels(shadowedStoredRowsOf(resaved))).toEqual(['LEGACY']); }); it('the binding decides, never a body stamp: a package-less row stamping the pair is not the override', () => { const held = override(); const stamped = bindStoredRow(body('STAMPED', { _packageId: PAIR, overrides: { package: BASE, basedOn: '1.0.0' } }), null); - expect(incomingTakesStoredFlowSlot(held, stamped, BASE)).toBe(false); + expect(incomingTakesStoredFlowSlot(held, stamped, BASE, NOTHING_INSTALLED)).toBe(false); const heldStamped = bindStoredRow(body('STAMPED', { _packageId: PAIR, overrides: { package: BASE, basedOn: '1.0.0' } }), null); - expect(incomingTakesStoredFlowSlot(heldStamped, legacy(), BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(heldStamped, legacy(), BASE, NOTHING_INSTALLED)).toBe(true); }); it('control: with no declared override among them, the last row read takes the slot and nothing is carried', () => { const held = legacy(); const undeclaredPair = bindStoredRow(body('PAIR, UNDECLARED'), PAIR); - expect(incomingTakesStoredFlowSlot(held, undeclaredPair, BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(held, undeclaredPair, BASE, NOTHING_INSTALLED)).toBe(true); expect(shadowedStoredRowsOf(undeclaredPair)).toEqual([]); - expect(incomingTakesStoredFlowSlot(undeclaredPair, legacy(), BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(undeclaredPair, legacy(), BASE, NOTHING_INSTALLED)).toBe(true); }); it('control: an empty slot, a slot holding no stored row, or a name no package ships, settles nothing', () => { - expect(incomingTakesStoredFlowSlot(undefined, legacy(), BASE)).toBe(true); - expect(incomingTakesStoredFlowSlot(body('LOADER', { _packageId: BASE }), legacy(), BASE)).toBe(true); - expect(incomingTakesStoredFlowSlot(override(), legacy(), undefined)).toBe(true); + expect(incomingTakesStoredFlowSlot(undefined, legacy(), BASE, NOTHING_INSTALLED)).toBe(true); + expect(incomingTakesStoredFlowSlot(body('LOADER', { _packageId: BASE }), legacy(), BASE, NOTHING_INSTALLED)).toBe(true); + expect(incomingTakesStoredFlowSlot(override(), legacy(), undefined, NOTHING_INSTALLED)).toBe(true); }); it('the carried rows never leave the process', () => { const held = override(); - incomingTakesStoredFlowSlot(held, legacy(), BASE); + incomingTakesStoredFlowSlot(held, legacy(), BASE, NOTHING_INSTALLED); expect(Object.keys(held)).not.toContain('shadowedStoredRows'); expect(JSON.parse(JSON.stringify(held))).toEqual(body('OVERRIDE', { overrides: { package: BASE, basedOn: '1.0.0' } })); expect(shadowedStoredRowsOf({ ...held })).toEqual([]); diff --git a/packages/metadata-protocol/src/stored-flow-slot.ts b/packages/metadata-protocol/src/stored-flow-slot.ts index af347018e57..c08bc22bb9d 100644 --- a/packages/metadata-protocol/src/stored-flow-slot.ts +++ b/packages/metadata-protocol/src/stored-flow-slot.ts @@ -36,6 +36,7 @@ * is carried once. Like the binding, it never leaves the process. */ +import type { InstalledManifestLookup } from '@objectstack/metadata-core'; import { isServedOverrideRowOf } from './served-override.js'; import { storedRowBindingOf } from './stored-row-binding.js'; @@ -85,7 +86,12 @@ export function shadowedStoredRowsOf(item: unknown): readonly object[] { * Returns `true` when `incoming` takes the slot, `false` when the held * declared override keeps it. Records the row that loses on the one that wins. */ -export function incomingTakesStoredFlowSlot(held: unknown, incoming: unknown, basePackageId: unknown): boolean { +export function incomingTakesStoredFlowSlot( + held: unknown, + incoming: unknown, + basePackageId: unknown, + installed: InstalledManifestLookup, +): boolean { const heldBinding = storedRowBindingOf(held); // An empty slot, or one holding no stored row: nothing to settle. if (heldBinding === undefined || held === null || typeof held !== 'object') return true; @@ -97,7 +103,7 @@ export function incomingTakesStoredFlowSlot(held: unknown, incoming: unknown, ba return true; } const isOverride = (item: unknown, binding: string | null | undefined) => - isServedOverrideRowOf({ type: 'flow', package_id: binding ?? null, metadata: item }, basePackageId); + isServedOverrideRowOf({ type: 'flow', package_id: binding ?? null, metadata: item }, basePackageId, installed); const incomingIsOverride = isOverride(incoming, incomingBinding); if (!incomingIsOverride && isOverride(held, heldBinding)) { carryShadowed(held, held, incoming); diff --git a/packages/objectql/src/plugin-authored-actions.test.ts b/packages/objectql/src/plugin-authored-actions.test.ts index 5bef2246fc3..a3e14017423 100644 --- a/packages/objectql/src/plugin-authored-actions.test.ts +++ b/packages/objectql/src/plugin-authored-actions.test.ts @@ -19,6 +19,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ObjectQLPlugin } from './plugin.js'; import { ObjectQL } from './engine.js'; import { isServedOverrideRowOf } from '@objectstack/metadata-protocol'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); type AnyRecord = Record; @@ -448,7 +452,7 @@ describe('[#30065] a declared .custom action override rebinds the body, and rest expect(warn.mock.calls[0][1]).toMatchObject({ package: `app:${BASE}`, replacedPackage: 'metadata-service' }); // The reads judge the row against the real base by the one served-override // predicate, and decline it: the run now agrees with them. - expect([isServedOverrideRowOf(row as any, BASE), isServedOverrideRowOf(row as any, OTHER)]).toEqual([false, true]); + expect([isServedOverrideRowOf(row as any, BASE, NOTHING_INSTALLED), isServedOverrideRowOf(row as any, OTHER, NOTHING_INSTALLED)]).toEqual([false, true]); // The next resync judges the row against the registered artifact and // leaves the base's handler bound. await resync([row]); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index d4133a9efc5..b54d36f2d16 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -11,7 +11,7 @@ import { import { OWNERSHIP_QUEUE_SOURCE_SLOT, describeMissingOwnershipQueueService } from './assignment-rules.js'; import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; import { assembleMetadataProtocol, isServedOverrideRowOf } from '@objectstack/metadata-protocol'; -import { isTenantAuthored, readLegacyOrganizationRows } from '@objectstack/metadata-core'; +import { installedManifestsOf, isTenantAuthored, readLegacyOrganizationRows } from '@objectstack/metadata-core'; import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol'; import { Plugin, PluginContext } from '@objectstack/core'; import { resolveArtifactPackageEntries, artifactPackageId, readDeploymentOrgScopingEntitlement } from '@objectstack/core'; @@ -3839,14 +3839,15 @@ export class ObjectQLPlugin implements Plugin { private isDeclaredArtifactActionOverride(row: unknown, action: any): boolean { const registry: any = this.ql?.registry; if (!registry || typeof registry.getArtifactItem !== 'function') return false; + const installed = installedManifestsOf(registry); const artifact: any = registry.getArtifactItem('action', action.name); if (artifact === undefined || artifact === null) { - return isServedOverrideRowOf(row as any, action?.overrides?.package); + return isServedOverrideRowOf(row as any, action?.overrides?.package, installed); } const base = artifact?._packageId; if (typeof base !== 'string' || base === '') return false; if (standaloneActionOwnerKey(artifact) !== standaloneActionOwnerKey(action)) return false; - return isServedOverrideRowOf(row as any, base); + return isServedOverrideRowOf(row as any, base, installed); } /** diff --git a/packages/objectql/src/protocol-custom-override-effective-read.test.ts b/packages/objectql/src/protocol-custom-override-effective-read.test.ts index 0f2815cd811..883d0484645 100644 --- a/packages/objectql/src/protocol-custom-override-effective-read.test.ts +++ b/packages/objectql/src/protocol-custom-override-effective-read.test.ts @@ -43,9 +43,12 @@ import { isServedOverrideRowOf, resetEnvWritableMetadataTypes, } from '@objectstack/metadata-protocol'; -import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, installedManifestsOf } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); + const BASE = 'com.example.showcase'; const PAIR = 'com.example.showcase.custom'; const DEPENDENT = 'com.default.acme-custom'; @@ -308,7 +311,7 @@ describe('[#28409] the five reads of a name X ships serve X\'s declared `.custom await install(protocol, PAIR); await saveInto(protocol, PAIR, OVERRIDE_COLUMNS, { overrides: { ...DECLARATION } }); expect(rowsOf(VIEW).map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowsOf(VIEW)[0] as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowsOf(VIEW)[0] as any, BASE, NOTHING_INSTALLED)).toBe(true); const packageless = await read(protocol); expect(columnsOf(packageless)).toEqual(OVERRIDE_COLUMNS); diff --git a/packages/objectql/src/protocol-flow-custom-override.test.ts b/packages/objectql/src/protocol-flow-custom-override.test.ts index 2409042c489..e0db1841319 100644 --- a/packages/objectql/src/protocol-flow-custom-override.test.ts +++ b/packages/objectql/src/protocol-flow-custom-override.test.ts @@ -30,10 +30,13 @@ import { storedRowBindingOf, SysMetadataRepository, } from '@objectstack/metadata-protocol'; -import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, installedManifestsOf } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; import { InMemoryActionActivationStore } from './action-activation.js'; +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); + const BASE = 'com.example.showcase'; const PAIR = 'com.example.showcase.custom'; const DEPENDENT = 'com.default.acme-custom'; @@ -249,7 +252,7 @@ describe('[#28412] a declared .custom flow override is the packaged flow, at eve await install(protocol, PAIR); await saveInto(protocol, PAIR, 'OVERRIDE', { overrides: { ...DECLARATION } }); expect(rows().map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rows()[0] as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rows()[0] as any, BASE, NOTHING_INSTALLED)).toBe(true); for (const scope of [undefined, BASE]) { const item = await read(protocol, scope); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index 128f648e6d9..74c4f43ee91 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -46,9 +46,12 @@ import { isServedOverrideRowOf, resetEnvWritableMetadataTypes, } from '@objectstack/metadata-protocol'; -import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, installedManifestsOf } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); + const BASE = 'com.example.showcase'; const PAIR = 'com.example.showcase.custom'; const OTHER = 'com.example.other'; @@ -279,7 +282,7 @@ describe('[#28413] the census and the move', () => { expect(rows(VIEW).slice().sort()).toEqual([[PAIR, 'active'], [PAIR, 'draft']]); for (const state of ['active', 'draft']) { const moved = rowOf(VIEW, PAIR, state)!; - expect(isServedOverrideRowOf(moved as any, BASE), state).toBe(true); + expect(isServedOverrideRowOf(moved as any, BASE, NOTHING_INSTALLED), state).toBe(true); expect(JSON.parse(String(moved.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); } expect(columnsOf(JSON.parse(String(rowOf(VIEW, PAIR, 'draft')!.metadata)))).toEqual(EDITED_COLUMNS); @@ -422,7 +425,7 @@ describe('[#28576] after the close: a package-less write onto a packaged item is expectClosed(await refusalOf(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, packageId: null } as any))); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE, NOTHING_INSTALLED)).toBe(true); expect(await fiveReads(protocol)).toEqual([UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS, UNBOUND_COLUMNS]); }); } @@ -454,7 +457,7 @@ describe('[#28576] after the close: a package-less write onto a packaged item is item: { ...listBody(VIEW, 'showcase_task', EDITED_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, } as any); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE, NOTHING_INSTALLED)).toBe(true); expect(await fiveReads(protocol)).toEqual([EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS, EDITED_COLUMNS]); await protocol.deleteMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any); @@ -507,7 +510,7 @@ describe('[#28576] after the close: a package-less write onto a packaged item is item: { ...listBody(VIEW, 'showcase_task', UNBOUND_COLUMNS), overrides: { package: BASE, basedOn: '1.2.0' } }, } as any); expect(rows(VIEW)).toEqual([[PAIR, 'active']]); - expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE)).toBe(true); + expect(isServedOverrideRowOf(rowOf(VIEW, PAIR) as any, BASE, NOTHING_INSTALLED)).toBe(true); expect(columnsOf(await read(protocol))).toEqual(UNBOUND_COLUMNS); }); @@ -688,7 +691,7 @@ describe('[#29392] the census and the move reach a row bound to the read-only ba expect(rows(VIEW).slice().sort()).toEqual([[PAIR, 'active'], [PAIR, 'draft']]); for (const state of ['active', 'draft']) { const moved = rowOf(VIEW, PAIR, state)!; - expect(isServedOverrideRowOf(moved as any, BASE), state).toBe(true); + expect(isServedOverrideRowOf(moved as any, BASE, NOTHING_INSTALLED), state).toBe(true); expect(JSON.parse(String(moved.metadata)).overrides).toEqual({ package: BASE, basedOn: '1.2.0' }); } expect(columnsOf(JSON.parse(String(rowOf(VIEW, PAIR, 'draft')!.metadata)))).toEqual(EDITED_COLUMNS); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 689534c3d25..91d5e681746 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -44,8 +44,9 @@ import { type FieldPatchTargetMiss, // [ADR-0146 D3 · ruling record 6691022506] The pair of a package, looked up // through the App that carries it — the uninstall refusal's "is its pair - // installed?" asks it. + // installed?" asks it, over this registry's installed manifests. pairOf, + installedManifestsOf, } from '@objectstack/metadata-core'; // [ADR-0029 D9.2a] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather // than re-spelled: the object FOLD asks the same question one layer down (has @@ -7333,7 +7334,7 @@ export class SchemaRegistry { extended.push({ objectName: fqn, extenders: otherExtenders.map(c => String(c.packageId)) }); } } - const pair = pairOf(packageId); + const pair = pairOf(packageId, installedManifestsOf(this)); const installedPair = pair.ok && this.getPackage(pair.value) !== undefined ? pair.value : undefined; if (extended.length > 0 || installedPair !== undefined) { throw new PackageUninstallExtendedError(packageId, extended, installedPair); diff --git a/packages/runtime/src/domains/packages-ensure-customization.test.ts b/packages/runtime/src/domains/packages-ensure-customization.test.ts index 3f818e28c16..c89aae30e10 100644 --- a/packages/runtime/src/domains/packages-ensure-customization.test.ts +++ b/packages/runtime/src/domains/packages-ensure-customization.test.ts @@ -58,8 +58,13 @@ const TENANT_ADMIN = { userId: 'u_tenant', tenantId: 'org_a', systemPermissions: /** The platform operator rung. */ const OPERATOR = { userId: 'u_operator', systemPermissions: ['manage_metadata'], posture: 'PLATFORM_ADMIN' }; -/** The base: an installed app the customer cannot author into (a platform scope). */ -const CRM = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm', scope: 'system' }; +/** + * The base: an installed app the customer cannot author into — read-only + * because the runtime holds it as code, the way an installed app's packages + * are held. Not a `system` scope: that is the kernel's, which has no pair in + * this version (ruling record 6691217245). + */ +const CRM = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm' }; const PAIR_ID = 'com.acme.crm.custom'; const ENSURE_PATH = `POST /api/v1/packages/${CRM.id}/ensure-customization`; @@ -126,9 +131,10 @@ function register(d: Door, manifest: Record, source: 'marketpla d.registry.installPackage(manifest as any, source); } -/** The base installed as production holds it: an installed app of a platform scope. */ +/** The base installed as production holds it: an installed app, held as code. */ function withBase(d: Door, base: Record = CRM): Door { - register(d, base); + register(d, base, 'code'); + d.engine.manifests.set(base.id as string, base); return d; } @@ -239,6 +245,38 @@ describe(`${ENSURE_PATH}: each refusal is worded and leaves nothing`, () => { expectRefusal(r, 409, 'RESOURCE_CONFLICT', `Package '${own.id}' is writable: customize it directly`); }); + // [ruling record 6691217245] The kernel — the packages of scope `system` + // and `cloud` — ships with the runtime and is installed whole: no kernel + // object is customizable in this version, so the door ensures no pair for + // one. A narrowing: before it, this call created `.custom`. + for (const scope of ['system', 'cloud'] as const) { + it(`a kernel base (scope '${scope}'): 409, not customizable in this version, nothing created`, async () => { + const d = door(); + const kernel = { id: 'com.objectstack.plugin-auth', name: 'Auth', version: '18.3.0', type: 'plugin', namespace: 'sys', scope }; + register(d, kernel, 'code'); + const r = await leavesNothing(d, () => ensure(d, kernel.id)); + expectRefusal(r, 409, 'RESOURCE_CONFLICT', + `Package '${kernel.id}' is a kernel package (scope '${scope}')`, + 'kernel objects are not customizable in this version', + `'${kernel.id}.custom' is not ensured`); + expect(d.registry.getPackage(`${kernel.id}.custom`)).toBeUndefined(); + }); + } + + it('a kernel pair an earlier version created stays installed: only the ensure call is refused', async () => { + const d = door(); + const kernel = { id: 'com.objectstack.plugin-auth', name: 'Auth', version: '18.3.0', type: 'plugin', namespace: 'sys', scope: 'system' }; + register(d, kernel, 'code'); + // The pair today's door would have created for it, as it would have written it. + const legacy = { id: `${kernel.id}.custom`, name: 'Auth (custom)', version: '0.1.0', type: 'module', namespace: 'sys_custom', dependencies: { [kernel.id]: '^18' } }; + expect((await install(d, legacy)).response.status).toBe(201); + const before = structuredClone(d.registry.getPackage(legacy.id)); + const r = await leavesNothing(d, () => ensure(d, kernel.id)); + expectRefusal(r, 409, 'RESOURCE_CONFLICT', 'A pair an earlier version created stays installed'); + expect(d.registry.getPackage(legacy.id)).toEqual(before); + expect((await read(d, legacy.id)).response.status).toBe(200); + }); + it('the base is itself a pair: 400, a pair is never paired again', async () => { const d = withBase(door()); expect((await ensure(d, CRM.id)).response.status).toBe(201); @@ -248,13 +286,13 @@ describe(`${ENSURE_PATH}: each refusal is worded and leaves nothing`, () => { }); it('the base declares no namespace: 409', async () => { - const d = withBase(door(), { id: 'com.acme.bare', name: 'Bare', version: '1.0.0', type: 'app', scope: 'system' }); + const d = withBase(door(), { id: 'com.acme.bare', name: 'Bare', version: '1.0.0', type: 'app' }); const r = await leavesNothing(d, () => ensure(d, 'com.acme.bare')); expectRefusal(r, 409, 'RESOURCE_CONFLICT', "package 'com.acme.bare' declares no namespace"); }); it('the composed namespace fails the manifest grammar: 409, never truncated', async () => { - const d = withBase(door(), { id: 'com.acme.long', name: 'Long', version: '1.0.0', type: 'app', namespace: 'abcdefghijklmn', scope: 'system' }); + const d = withBase(door(), { id: 'com.acme.long', name: 'Long', version: '1.0.0', type: 'app', namespace: 'abcdefghijklmn' }); const r = await leavesNothing(d, () => ensure(d, 'com.acme.long')); expectRefusal(r, 409, 'RESOURCE_CONFLICT', "'abcdefghijklmn_custom' exceeds the 20 characters", 'none is truncated or hashed'); }); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index efdec37c82f..d796baff1bd 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -37,7 +37,7 @@ import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, isCodeArtifactBody } from // sources, each read from its owner: the pair's id and a pair's base // (`@objectstack/spec/kernel`, the one spelling of the suffix), and the // activation ledger's one reader (`@objectstack/core`). -import { customizationBasePackageId, customizationPackageId } from '@objectstack/spec/kernel'; +import { customizationBasePackageId } from '@objectstack/spec/kernel'; import { listPackageMetadataActivations, METADATA_ACTIVATION_TABLE } from '@objectstack/core'; import type { GetPackageCustomizationsResponse } from '@objectstack/spec/api'; // [ADR-0131 D3 · D12 items 7–8] The walled refusal's one predicate, asked by @@ -57,6 +57,11 @@ import { isProtocolIncompatibleError, protocolIncompatibleAnswer, type InstalledDependencyRecord, + // [ADR-0146 D3 · ruling record 6691022506] The pair of a package, looked up + // through the App that carries it, over the engine registry's manifests. + installedManifestsOf, + pairOf, + type InstalledPackageSource, } from '@objectstack/metadata-core'; // [#7560] ADR-0070's read-only-package rule — the SAME predicate the metadata // authoring path asks before refusing a write INTO a platform package @@ -657,19 +662,21 @@ function packageIsUnknown(registry: unknown, id: string): boolean { * `capability`, the code-only types) is refused at the save door naming * the route that type has ("new similar", the switch), and a locked item * `ITEM_LOCKED` naming the lock (ADR-0010 §3.3); - * - a package the ensure door cannot pair — no namespace, a pair namespace - * another package owns, a version that is not SemVer — is refused there, + * - a package the ensure door cannot pair — a kernel package, no namespace, + * a pair namespace another package owns, a version that is not SemVer — is + * refused there, * naming why (ADR-0146 D3). * Both refusals name their own reason, so the sentence names the door rather * than a list of types or package shapes that could drift from them. * - * An id the pairing rule derives no pair for (`customizationPackageId`, - * `@objectstack/spec/kernel`) gets the sentence without a pair id: the ensure - * door answers that id with the reason it has none. + * An id the pair lookup derives no pair for (`pairOf`, + * `@objectstack/metadata-core`, over the engine registry's installed + * manifests) gets the sentence without a pair id: the ensure door answers that + * id with the reason it has none. */ -function customizeInPairRemedy(id: string): string { +function customizeInPairRemedy(id: string, engine: unknown): string { const ensure = `POST /api/v1/packages/${id}/ensure-customization`; - const pair = customizationPackageId(id); + const pair = pairOf(id, installedManifestsOf((engine as { registry?: InstalledPackageSource } | null | undefined)?.registry)); if (!pair.ok) { return `customize what it ships in its paired customization package (ADR-0146 D3): ${ensure} answers ` + 'that package, or why this one has none.'; @@ -694,7 +701,7 @@ function requireWritablePackage( `[writable_package_required] Cannot ${action} package '${id}': it is read-only ` + `(provided by code or an installed app). Packages the deployment ships are managed by ` + `the deployment, not over this API — ${action} a package you own, or ` - + customizeInPairRemedy(id), + + customizeInPairRemedy(id, engine), 422, { code: 'WRITABLE_PACKAGE_REQUIRED', @@ -1492,7 +1499,7 @@ function requireDuplicableSource( + `(provided by code or an installed app), and duplicate clones a writable base's ` + `stored metadata rows. A code package's metadata is delivered as code, so there are ` + `no rows to clone and the copy would be empty (ADR-0070 D4 duplicates a BASE). ` - + `Duplicate a base you own, or ${customizeInPairRemedy(id)} ` + + `Duplicate a base you own, or ${customizeInPairRemedy(id, engine)} ` + `For an editable copy of the whole package, install it unmanaged instead ` + `(installMode 'unmanaged'), where its manifest's installModes permits that.`, 422, @@ -3982,8 +3989,8 @@ async function readPackageCustomizations( ): Promise { const overrides = await listServedOverrides({ packageId: baseId }); - const pair = customizationPackageId(baseId); const registry = ql?.registry; + const pair = pairOf(baseId, installedManifestsOf(registry)); type Extension = GetPackageCustomizationsResponse['extensions'][number]; const extensions: Extension[] = []; if (pair.ok && typeof registry?.getAllObjects === 'function') { diff --git a/packages/services/service-automation/src/flow-name-shadowing.test.ts b/packages/services/service-automation/src/flow-name-shadowing.test.ts index a395ae6fd3c..190cfd07835 100644 --- a/packages/services/service-automation/src/flow-name-shadowing.test.ts +++ b/packages/services/service-automation/src/flow-name-shadowing.test.ts @@ -32,6 +32,10 @@ import { SchemaRegistry } from '@objectstack/objectql'; import { AutomationEngine } from './engine.js'; import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js'; import { bindStoredRow } from '@objectstack/metadata-protocol'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const FLOW = 'opportunity_approval'; const silentLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any; @@ -92,7 +96,7 @@ function bootPull(registry: any, logger: { warn(m: string, meta?: unknown): void const engine = new AutomationEngine(silentLogger); engine.setPackagedFlowSource(loaderSetOf(registry)); const listed = registry.listItems('flow') as unknown[]; - const resolved = resolveFlowPrecedence(listed, logger, (name) => engine.packagedFlowOwner(name)); + const resolved = resolveFlowPrecedence(listed, logger, (name) => engine.packagedFlowOwner(name), NOTHING_INSTALLED); for (const entry of resolved) { engine.registerFlow(entry.name, entry.definition as never); if (entry.shadowing) engine.recordFlowShadowing(entry.shadowing); @@ -212,8 +216,8 @@ describe('#11997 — precedence is a total order, not an iteration order', () => // The loader's set names ONE owner per name; the second package's // entry is told apart by the registry's per-entry artifact test. const loaderSet = setOf({ [FLOW]: 'alpha' }); - const forward = resolveFlowPrecedence([a, b], silentLogger, loaderSet); - const backward = resolveFlowPrecedence([b, a], silentLogger, loaderSet); + const forward = resolveFlowPrecedence([a, b], silentLogger, loaderSet, NOTHING_INSTALLED); + const backward = resolveFlowPrecedence([b, a], silentLogger, loaderSet, NOTHING_INSTALLED); expect((forward[0].definition as any).label).toBe('FROM_ALPHA'); expect((backward[0].definition as any).label).toBe('FROM_ALPHA'); @@ -225,7 +229,7 @@ describe('#11997 — precedence is a total order, not an iteration order', () => const after = { ...flowBody('zzz_last', 'Z') }; const resolved = resolveFlowPrecedence( [before, packagedBody(), after, runtimeBody()], - silentLogger, + silentLogger, undefined, NOTHING_INSTALLED, ); expect(resolved.map((r) => r.name)).toEqual(['aaa_first', FLOW, 'zzz_last']); }); @@ -237,16 +241,16 @@ describe('#11997 — precedence is a total order, not an iteration order', () => // `loadMetaFromDb` rehydrates overlay rows with a synthetic // `_packageId = 'sys_metadata'` (ADR-0005 §Provenance edge case). A // bare `_packageId` truthiness test would misread it as packaged. - expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' }, loaderSet)).toEqual({ + expect(describeFlowContender({ name: FLOW, _packageId: 'sys_metadata' }, loaderSet, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'sys_metadata', }); // And a tenant-authored overlay bound to a REAL package id is runtime // too — provenance is the axis, not the id (cloud#970). expect( - describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }, loaderSet), + describeFlowContender({ name: FLOW, _packageId: 'app.built_by_studio', _provenance: 'org' }, loaderSet, NOTHING_INSTALLED), ).toEqual({ source: 'runtime', packageId: 'app.built_by_studio' }); - expect(describeFlowContender({ name: FLOW, _packageId: 'crm' }, loaderSet)).toEqual({ + expect(describeFlowContender({ name: FLOW, _packageId: 'crm' }, loaderSet, NOTHING_INSTALLED)).toEqual({ source: 'package', packageId: 'crm', }); @@ -256,7 +260,7 @@ describe('#11997 — precedence is a total order, not an iteration order', () => const packaged = { ...flowBody(FLOW, 'PACKAGED'), _packageId: 'crm' }; const tenant = { ...flowBody(FLOW, 'TENANT'), _packageId: 'crm', _provenance: 'org' }; for (const listed of [[packaged, tenant], [tenant, packaged]]) { - const resolved = resolveFlowPrecedence(listed, silentLogger, setOf({ [FLOW]: 'crm' })); + const resolved = resolveFlowPrecedence(listed, silentLogger, setOf({ [FLOW]: 'crm' }), NOTHING_INSTALLED); expect((resolved[0].definition as any).label).toBe('PACKAGED'); } }); diff --git a/packages/services/service-automation/src/flow-precedence-loader-set.test.ts b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts index 918d57ff43e..e5b78f2a85d 100644 --- a/packages/services/service-automation/src/flow-precedence-loader-set.test.ts +++ b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts @@ -22,6 +22,10 @@ import { AutomationServicePlugin } from './plugin.js'; import type { AutomationEngine, FlowContender } from './engine.js'; import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js'; import { bindStoredRow } from '@objectstack/metadata-protocol'; +import { installedManifestsOf } from '@objectstack/metadata-core'; + +/** Nothing installed: the pair lookup reads no manifest while `appOf` is the identity. */ +const NOTHING_INSTALLED = installedManifestsOf(undefined); const FLOW = 'order_sync'; @@ -59,29 +63,29 @@ const silent = () => ({ warn: vi.fn() }); describe('[#20864] boot-time precedence classifies by the loader\'s set, not by body stamps', () => { it('a contender claiming a package ranks as tenant-authored when the set does not hold its name', () => { const claimed = loaderEntry(); - expect(describeFlowContender(claimed, HOLDS_NOTHING)).toEqual({ source: 'runtime', packageId: 'crm' }); + expect(describeFlowContender(claimed, HOLDS_NOTHING, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm' }); // The set is keyed by NAME: holding another flow vouches for nothing here. - expect(describeFlowContender(claimed, setOf({ other_flow: 'crm' }))).toEqual({ + expect(describeFlowContender(claimed, setOf({ other_flow: 'crm' }), NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm', }); // The operator reads it as the tenant row it is; the claimed package // never reaches the sentence. - expect(renderFlowContender(describeFlowContender(claimed, HOLDS_NOTHING))).toBe( + expect(renderFlowContender(describeFlowContender(claimed, HOLDS_NOTHING, NOTHING_INSTALLED))).toBe( renderFlowContender({ source: 'runtime' }), ); }); it('the same contender ranks as packaged when the set holds its name', () => { - expect(describeFlowContender(loaderEntry(), HOLDS_FLOW)).toEqual({ source: 'package', packageId: 'crm' }); + expect(describeFlowContender(loaderEntry(), HOLDS_FLOW, NOTHING_INSTALLED)).toEqual({ source: 'package', packageId: 'crm' }); }); it('a stored tenant row stays tenant-authored inside a name the set holds', () => { - expect(describeFlowContender(tenantRow(), HOLDS_FLOW)).toEqual({ source: 'runtime', packageId: 'app.ops' }); + expect(describeFlowContender(tenantRow(), HOLDS_FLOW, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'app.ops' }); }); it('the same two bodies rank by the set: only a held name puts a packaged contender in the shadowing record', () => { - const held = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_FLOW); + const held = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_FLOW, NOTHING_INSTALLED); expect(held).toHaveLength(1); // [#20913, ADR-0126 §2] Inside a held name the loader's entry is armed. expect((held[0].definition as { label: string }).label).toBe('LOADER'); @@ -91,7 +95,7 @@ describe('[#20864] boot-time precedence classifies by the loader\'s set, not by shadowed: [{ source: 'runtime', packageId: 'app.ops' }], }); - const unheld = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING); + const unheld = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING, NOTHING_INSTALLED); expect(unheld[0].shadowing).toEqual({ name: FLOW, armed: { source: 'runtime', packageId: 'app.ops' }, @@ -105,7 +109,7 @@ describe('[#20864] boot-time precedence classifies by the loader\'s set, not by [tenantRow(), loaderEntry()], ]) { const logger = silent(); - const [winner] = resolveFlowPrecedence(listed, logger, HOLDS_FLOW); + const [winner] = resolveFlowPrecedence(listed, logger, HOLDS_FLOW, NOTHING_INSTALLED); expect((winner.definition as { label: string }).label).toBe('LOADER'); expect(winner.shadowing?.armed).toEqual({ source: 'package', packageId: 'crm' }); expect(winner.shadowing?.shadowed).toEqual([{ source: 'runtime', packageId: 'app.ops' }]); @@ -123,27 +127,27 @@ describe('[#20864] boot-time precedence classifies by the loader\'s set, not by // Both tenant-ranked (the set holds nothing). By id alone the tenant // row would sort first; arrival order decides instead, so a claimed id // cannot buy the armed slot. - const first = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_NOTHING); + const first = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_NOTHING, NOTHING_INSTALLED); expect((first[0].definition as { label: string }).label).toBe('LOADER'); - const second = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING); + const second = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING, NOTHING_INSTALLED); expect((second[0].definition as { label: string }).label).toBe('TENANT'); }); it('with no reader nothing is packaged: the engine\'s fail-closed answer', () => { - expect(describeFlowContender(loaderEntry())).toEqual({ source: 'runtime', packageId: 'crm' }); + expect(describeFlowContender(loaderEntry(), undefined, NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm' }); - const [winner] = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent()); + const [winner] = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), undefined, NOTHING_INSTALLED); const record = winner.shadowing!; expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']); // A reader answering an empty owner names no package either — the // normalization `AutomationEngine.packagedFlowOwner` applies. - expect(describeFlowContender(loaderEntry(), () => '')).toEqual({ source: 'runtime', packageId: 'crm' }); + expect(describeFlowContender(loaderEntry(), () => '', NOTHING_INSTALLED)).toEqual({ source: 'runtime', packageId: 'crm' }); }); it('asks the set once per contested name, and never for an uncontested one', () => { const reader = vi.fn(HOLDS_FLOW); - resolveFlowPrecedence([flowBody('lonely_flow', 'L', CLAIMED), loaderEntry(), tenantRow()], silent(), reader); + resolveFlowPrecedence([flowBody('lonely_flow', 'L', CLAIMED), loaderEntry(), tenantRow()], silent(), reader, NOTHING_INSTALLED); expect(reader.mock.calls).toEqual([[FLOW]]); }); }); @@ -170,14 +174,14 @@ describe('[#28412] the declared .custom override is armed over the loader\'s bod bindStoredRow(flowBody(FLOW, label, { ...body, _provenance: 'org' }), binding); it('classifies the pair\'s declared row as the override, by its stored binding', () => { - expect(describeFlowContender(stored(PAIR), HOLDS)).toEqual({ source: 'override', packageId: PAIR }); + expect(describeFlowContender(stored(PAIR), HOLDS, NOTHING_INSTALLED)).toEqual({ source: 'override', packageId: PAIR }); expect(renderFlowContender({ source: 'override', packageId: PAIR })).toBe(`the declared override in '${PAIR}' (sys_metadata)`); }); it('arms the override in either arrival order, with no warning and no receipt', () => { for (const listed of [[base(), stored(PAIR)], [stored(PAIR), base()]]) { const logger = silent(); - const winners = resolveFlowPrecedence(listed, logger, HOLDS); + const winners = resolveFlowPrecedence(listed, logger, HOLDS, NOTHING_INSTALLED); expect(winners).toHaveLength(1); expect((winners[0].definition as { label: string }).label).toBe('OVERRIDE'); expect(winners[0].shadowing).toBeUndefined(); @@ -194,7 +198,7 @@ describe('[#28412] the declared .custom override is armed over the loader\'s bod ] as const) { it(`${label} is not armed: the loader's body is, and the remedy names the declaration, not a clone`, () => { const logger = silent(); - const [winner] = resolveFlowPrecedence([row(), base()], logger, HOLDS); + const [winner] = resolveFlowPrecedence([row(), base()], logger, HOLDS, NOTHING_INSTALLED); expect((winner.definition as { label: string }).label).toBe('LOADER'); expect(winner.shadowing?.armed).toEqual({ source: 'package', packageId: OWNER }); expect(winner.shadowing?.shadowed.map((c) => c.source)).toEqual(['runtime']); @@ -206,12 +210,12 @@ describe('[#28412] the declared .custom override is armed over the loader\'s bod } it('an unheld name: a declared row is tenant-authored, the set vouches for no override', () => { - expect(describeFlowContender(stored(PAIR), HOLDS_NOTHING).source).toBe('runtime'); + expect(describeFlowContender(stored(PAIR), HOLDS_NOTHING, NOTHING_INSTALLED).source).toBe('runtime'); }); it('the override beside an undeclared stored row: the override is armed, the stored row shadowed and warned', () => { const logger = silent(); - const [winner] = resolveFlowPrecedence([stored(null, {}, 'TENANT'), base(), stored(PAIR)], logger, HOLDS); + const [winner] = resolveFlowPrecedence([stored(null, {}, 'TENANT'), base(), stored(PAIR)], logger, HOLDS, NOTHING_INSTALLED); expect((winner.definition as { label: string }).label).toBe('OVERRIDE'); expect(winner.shadowing).toEqual({ name: FLOW, diff --git a/packages/services/service-automation/src/flow-precedence.ts b/packages/services/service-automation/src/flow-precedence.ts index 92373bbe509..25db2b1c807 100644 --- a/packages/services/service-automation/src/flow-precedence.ts +++ b/packages/services/service-automation/src/flow-precedence.ts @@ -85,7 +85,7 @@ // tsup config externalises only `dependencies`/`peerDependencies` the bundler // answered it by inlining objectql's implementation into this package's dist. // `pnpm check:undeclared-dep-imports` is the gate over that class. -import { isCodeArtifactBody, pairOf } from '@objectstack/metadata-core'; +import { isCodeArtifactBody, pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; // [#28412, ADR-0146 D9 item 1] The one served-override predicate (N-10) and // the stored row's own binding the protocol's hydration records — never a // second spelling of "is this the declared override", and never the body's @@ -140,13 +140,13 @@ function loaderSetOwner(packagedFlowOwner: PackagedFlowSource | undefined, name: * `runtime` contender keeps a body's id for display only — * {@link renderFlowContender} never prints it. */ -function classifyContender(item: unknown, owner: string | undefined): FlowContender { +function classifyContender(item: unknown, owner: string | undefined, installed: InstalledManifestLookup): FlowContender { const packageId = (item as { _packageId?: unknown } | null | undefined)?._packageId; // [#28412, ADR-0146 D9 item 1] Asked FIRST, and only inside a held name: // the owner's declared override, by its stored binding (see the header). if (owner !== undefined) { const binding = storedRowBindingOf(item); - if (isServedOverrideRowOf({ type: 'flow', package_id: binding, metadata: item }, owner)) { + if (isServedOverrideRowOf({ type: 'flow', package_id: binding, metadata: item }, owner, installed)) { return { source: 'override', packageId: binding as string }; } } @@ -181,9 +181,13 @@ function classifyContender(item: unknown, owner: string | undefined): FlowConten * cannot tell a tenant overlay bound to a package from a code artifact (see * `isTenantAuthored` in `@objectstack/metadata-core`, and cloud#970). */ -export function describeFlowContender(item: unknown, packagedFlowOwner?: PackagedFlowSource): FlowContender { +export function describeFlowContender( + item: unknown, + packagedFlowOwner: PackagedFlowSource | undefined, + installed: InstalledManifestLookup, +): FlowContender { const name = (item as { name?: unknown } | null | undefined)?.name; - return classifyContender(item, loaderSetOwner(packagedFlowOwner, name)); + return classifyContender(item, loaderSetOwner(packagedFlowOwner, name), installed); } /** @@ -254,8 +258,8 @@ function precedenceRank(contender: FlowContender): number { * ⛔ No longer "clone": the clone is a second flow beside the base, and both * run. */ -function undeclaredRemedy(owner: string | undefined): string { - const pair = owner === undefined ? undefined : pairOf(owner); +function undeclaredRemedy(owner: string | undefined, installed: InstalledManifestLookup): string { + const pair = owner === undefined ? undefined : pairOf(owner, installed); const where = pair?.ok ? `declare it as an override in '${pair.value}' (overrides: { package: '${owner}', basedOn })` : 'declare it as an override in the shipping package\'s paired .custom package'; @@ -304,11 +308,16 @@ function undeclaredRemedy(owner: string | undefined): string { * @param logger warned once per colliding name, naming both contenders * @param packagedFlowOwner the loader's set — the boot pull passes the engine's * `packagedFlowOwner`; absent, no contender is packaged + * @param installed the installed manifests of the registry the flows + * come from: the declared override of a shipped flow + * is its App's pair's (`pairOf` in + * `@objectstack/metadata-core`) */ export function resolveFlowPrecedence( items: readonly unknown[], - logger?: { warn(message: string, meta?: unknown): void }, - packagedFlowOwner?: PackagedFlowSource, + logger: { warn(message: string, meta?: unknown): void } | undefined, + packagedFlowOwner: PackagedFlowSource | undefined, + installed: InstalledManifestLookup, ): FlowPrecedenceWinner[] { // Group by bare name, remembering arrival order for a stable tie-break. const groups = new Map>(); @@ -336,7 +345,7 @@ export function resolveFlowPrecedence( const owner = loaderSetOwner(packagedFlowOwner, name); const group = members.map((member) => ({ ...member, - contender: classifyContender(member.definition, owner), + contender: classifyContender(member.definition, owner, installed), })); const ranked = [...group].sort((a, b) => { const byRank = precedenceRank(a.contender) - precedenceRank(b.contender); @@ -380,7 +389,7 @@ export function resolveFlowPrecedence( ? ' by package id' : ' by arrival order'; const remedy = sealedOverStored - ? undeclaredRemedy(owner) + ? undeclaredRemedy(owner, installed) : 'Rename one of them.'; logger?.warn( `[Automation] Flow name collision: '${name}' is claimed by ${group.length} definitions ` + diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index c03b93fbf75..a7c51c5c321 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -3,6 +3,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { effectiveTenancyPosture, isServiceNotRegisteredError, resolveLocalizationContext, resolveUserAuthzGrants } from '@objectstack/core'; import type { IClusterService, IJobService, IPubSub } from '@objectstack/spec/contracts'; +import { installedManifestsOf, type InstalledPackageSource } from '@objectstack/metadata-core'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; import type { Connector, @@ -591,6 +592,22 @@ export function nonHostDeclaringPackageOf( * `start()` reconcile keeps every stored entry, so none waits for a pass that * never comes. */ +/** + * The package registry ObjectQL serves, read where it is used, or `undefined` + * when no ObjectQL service is registered: the installed manifests the + * customization pair lookup reads (`installedManifestsOf`). Without one, no + * package is installed, and every package is its own App. + */ +function objectqlRegistryOf(ctx: PluginContext): InstalledPackageSource | undefined { + try { + return ctx.getService<{ registry?: InstalledPackageSource }>('objectql')?.registry; + } catch (error) { + // `getService` refuses a service no plugin registered: no registry. + if (isServiceNotRegisteredError(error)) return undefined; + throw error; + } +} + function kernelDispatchesBootPhaseHooks(ctx: PluginContext): boolean { let kernel: unknown; try { @@ -5224,6 +5241,7 @@ export class AutomationServicePlugin implements Plugin { items, ctx.logger, engine ? (name) => engine.packagedFlowOwner(name) : undefined, + installedManifestsOf(objectqlRegistryOf(ctx)), ); } diff --git a/packages/services/service-datasource/src/plugin.ts b/packages/services/service-datasource/src/plugin.ts index ad057ac0d10..2c6493e4eb2 100644 --- a/packages/services/service-datasource/src/plugin.ts +++ b/packages/services/service-datasource/src/plugin.ts @@ -8,7 +8,7 @@ import type { MetadataProtocol, } from '@objectstack/spec/api'; import { customizationNamespace } from '@objectstack/spec/kernel'; -import { pairOf } from '@objectstack/metadata-core'; +import { installedManifestsOf, pairOf } from '@objectstack/metadata-core'; import { postureEnforcesWall } from '@objectstack/spec/security'; import type { IDataEngine, IObjectQLEngine, IntrospectedSchema } from '@objectstack/spec/contracts'; import { @@ -220,11 +220,13 @@ export class ExternalDatasourceServicePlugin implements Plugin { } }; + /** The engine registry `installPackage` stores package records in, read where it is used. */ + const objectqlRegistry = (): { getPackage?: (id: string) => { manifest?: unknown } | undefined } | undefined => + safeGetService<{ registry?: { getPackage?: (id: string) => { manifest?: unknown } | undefined } }>(ctx, 'objectql')?.registry; + /** A package record as `installPackage` stored it in the engine registry, read where it is used. */ const packageRecord = (packageId: string): { manifest?: { namespace?: unknown } } | undefined => - safeGetService<{ registry?: { getPackage?: (id: string) => unknown } }>(ctx, 'objectql')?.registry?.getPackage?.( - packageId, - ) as { manifest?: { namespace?: unknown } } | undefined; + objectqlRegistry()?.getPackage?.(packageId) as { manifest?: { namespace?: unknown } } | undefined; const namespaceOf = (record: { manifest?: { namespace?: unknown } } | undefined): string | undefined => { const ns = record?.manifest?.namespace; @@ -370,7 +372,7 @@ export class ExternalDatasourceServicePlugin implements Plugin { if (doors.isWritablePackage(basePackageId)) { return { packageId: basePackageId, namespace: namespaceOf(base) }; } - const pairId = pairOf(basePackageId); + const pairId = pairOf(basePackageId, installedManifestsOf(objectqlRegistry())); if (!pairId.ok) { throw importLandingRefusal( `Datasource '${datasource}' is declared by read-only package '${basePackageId}', which has no ` From cca740484b201001cf64a59b587341b254d682ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:22:41 +0000 Subject: [PATCH 02/10] test(#29594): kernel verdict pins, legacy kernel pair stays served, closes reopen; changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- ...94-pair-lookup-installed-kernel-verdict.md | 28 +++++++++ ...col-custom-override-effective-read.test.ts | 58 ++++++++++++++++++- ...tomation-flow-clone-custom-package.test.ts | 11 +++- 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 .changeset/29594-pair-lookup-installed-kernel-verdict.md diff --git a/.changeset/29594-pair-lookup-installed-kernel-verdict.md b/.changeset/29594-pair-lookup-installed-kernel-verdict.md new file mode 100644 index 00000000000..7f423034d60 --- /dev/null +++ b/.changeset/29594-pair-lookup-installed-kernel-verdict.md @@ -0,0 +1,28 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': patch +'@objectstack/runtime': patch +'@objectstack/cli': patch +'@objectstack/cloud-connection': patch +'@objectstack/lint': patch +'@objectstack/service-automation': patch +'@objectstack/service-datasource': patch +--- + +feat(metadata-protocol, metadata-core)!: the ensure door creates no customization pair for a kernel package, and every pair question carries the installed manifests + +Clause-②: yes (narrowing) — `POST /api/v1/packages/:id/ensure-customization` (and the protocol's `ensureCustomizationPackage`) answers a kernel base, a package of scope `system` or `cloud`, with `409 RESOURCE_CONFLICT` and a sentence saying kernel objects are not customizable in this version, where it created `.custom`. Beside it, the exported `pairOf`, `isPairOf` and `appOf` (`@objectstack/metadata-core`), `isServedOverrideRowOf` and the `installed` member of `OverrideWriteClosure` (`@objectstack/metadata-protocol`), and `resolveFlowPrecedence` / `describeFlowContender` (`@objectstack/service-automation`) take the installed-manifest lookup as a required argument. +Metadata-protocol: none + + + +**BREAKING** for a caller that ensures the customization pair of a kernel package — a platform package of scope `system` (users, organizations, security, settings and the rest of the platform suite) or `cloud` — and for a TypeScript importer of the pair helpers named above. + +- **Why (ruling records 6691022506 and 6691217245 on ADR-0146 D3).** The customization unit is the App, and the kernel is not an App a customer owns: it ships with the runtime at the runtime's version and is always installed whole. In this version no kernel object is customizable, so the door creates no pair for one. Which kernel objects a later version opens (a whitelist) is that version's decision. +- **What the door answers.** `409 RESOURCE_CONFLICT`, the status and code the door already answers when a base's state rules the pair out (a writable base, no namespace, a version that is not SemVer). Nothing is created or written. Every other base is answered exactly as before. +- **A kernel pair that already exists stays.** An earlier version of the door may have created `.custom` on a deployment. It stays installed, the overrides inside it stay served by every read of the kernel item's name, and a further save into it is still admitted. Only the ensure call is refused. +- **The closes that named the pair.** A package-less save, or a save naming the kernel package, onto a kernel item was refused naming the pair as the fix. That fix no longer exists, so those two closes leave the save open, as they already do for every other base the ensure door refuses. +- **The lookup.** Which pair customizes a package is asked through the installed manifests of the registry the caller serves (`installedManifestsOf(registry)`). Today the answer is unchanged for every package: `appOf` is still the identity. The argument is threaded through every site now so that resolving a module to its App is one change in `appOf`. +- **`os validate`'s customization-namespace advisory** now skips a package that is itself a pair through `isCustomizationPackage`, the one spelling of that question. An id ending in `.custom` that no base derives (`a.custom.custom`, a bare `.custom`) is now judged like any other package. +- **Who moves, and the fix.** A script or client that ensured a kernel package's pair drops that call: the kernel item is served as it ships, and an existing pair keeps serving what it holds. A TypeScript importer of the helpers passes `installedManifestsOf(engine.registry)`, or `installedManifestsOf(undefined)` where it holds no registry. diff --git a/packages/objectql/src/protocol-custom-override-effective-read.test.ts b/packages/objectql/src/protocol-custom-override-effective-read.test.ts index 883d0484645..9e7b0e88462 100644 --- a/packages/objectql/src/protocol-custom-override-effective-read.test.ts +++ b/packages/objectql/src/protocol-custom-override-effective-read.test.ts @@ -168,8 +168,9 @@ const listBody = (columns: readonly string[]) => ({ }, }); -function baseManifest(): Record { +function baseManifest(extra: Record = {}): Record { return { + ...extra, id: BASE, name: 'showcase', version: '1.0.0', @@ -194,7 +195,7 @@ function baseManifest(): Record { }; } -async function boot() { +async function boot(base: Record = baseManifest()) { const engine = new ObjectQL(); const { driver, stores } = makeMemoryDriver(); engine.registerDriver(driver, true); @@ -203,7 +204,7 @@ async function boot() { for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { engine.registry.registerObject(o as any, 'test-package'); } - engine.registerApp(baseManifest()); + engine.registerApp(base); const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); const rowsOf = (name: string) => Array.from(stores.get('sys_metadata')?.values() ?? []) .filter((r) => r.type === 'view' && r.name === name); @@ -461,3 +462,54 @@ describe('[#28409] a same-named item outside X\'s closure is served under its ow expect(columnsOf(await read(protocol, OUTSIDE))).toEqual(DEPENDENT_COLUMNS); }); }); + +/** + * [#29594, ruling record 6691217245] The ensure door creates no pair for a + * kernel package (scope `system` / `cloud`) in this version. Before that + * verdict it did, so a deployment may already hold a kernel base's + * `.custom` with overrides in it. The verdict refuses the ENSURE call + * only: the pair stays installed, the overrides in it stay served by every + * read of the base's name, and a further override saved into it is admitted + * by the write gate, which never asks the ensure door. + */ +describe('[#29594] a kernel base\'s pair an earlier version created keeps serving its overrides', () => { + it('the ensure call is refused 409; the override already in the pair is served, and another save into it lands', async () => { + const { protocol, rowsOf } = await boot(baseManifest({ scope: 'system' })); + // The pair as today's ensure door would have written it. + await install(protocol, PAIR); + await saveInto(protocol, PAIR, OVERRIDE_COLUMNS, { overrides: { ...DECLARATION } }); + expect(rowsOf(VIEW).map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]); + + await expect(protocol.ensureCustomizationPackage({ basePackageId: BASE })) + .rejects.toMatchObject({ statusCode: 409, message: expect.stringContaining(`Package '${BASE}' is a kernel package (scope 'system')`) }); + + for (const scope of [undefined, BASE]) { + const served = await read(protocol, scope); + expect(columnsOf(served), `read ${scope ?? '(no package)'}`).toEqual(OVERRIDE_COLUMNS); + expect(served._packageId).toBe(BASE); + expect(columnsOf((await layered(protocol, scope)).effective)).toEqual(OVERRIDE_COLUMNS); + } + expect(await listed(protocol)).toEqual([[BASE, OVERRIDE_COLUMNS]]); + + // A further change to the override is still written into the pair. + await saveInto(protocol, PAIR, BASE_COLUMNS.slice(0, 3), { overrides: { ...DECLARATION } }); + expect(columnsOf(await read(protocol))).toEqual(BASE_COLUMNS.slice(0, 3)); + }); + + it('the closes that named the pair leave a kernel item\'s save open: there is no pair to send it to', async () => { + const body = listBody(UNBOUND_COLUMNS); + // Control: an App's item — the package-less save and the save naming the base are closed onto the pair. + const app = await boot(); + await expect(app.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, message: expect.stringContaining(PAIR) }); + await expect(app.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId: BASE } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, message: expect.stringContaining(PAIR) }); + + // A kernel item: the ensure door creates no pair, so neither close names one. + const kernel = await boot(baseManifest({ scope: 'system' })); + await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any); + expect(kernel.rowsOf(VIEW).map((r) => r.package_id ?? null)).toEqual([null]); + await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId: BASE } as any); + expect(kernel.rowsOf(VIEW).map((r) => r.package_id ?? null)).toEqual([null, BASE]); + }); +}); diff --git a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts index f6c631ab5f0..e9054b535a8 100644 --- a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts +++ b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts @@ -63,8 +63,12 @@ afterAll(() => { /** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ const TEST_PACKAGE_ID = 'objectstack-test'; -/** The base: an installed app the customer cannot author into (a platform scope). */ -const BASE = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm', scope: 'system' }; +/** + * The base: an installed app the customer cannot author into — held as code, + * the way an installed app's packages are. Not a `system` scope: that is the + * kernel's, which has no pair in this version (ruling record 6691217245). + */ +const BASE = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm' }; const PAIR = 'com.acme.crm.custom'; /** A writable package that depends on the base: a legal explicit target. */ const EXT = { id: 'com.acme.ext', name: 'Ext', version: '1.0.0', type: 'module', namespace: 'ext', dependencies: { [BASE.id]: '^3' } }; @@ -118,7 +122,8 @@ async function boot(options: { parse?: boolean } = {}) { engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); await engine.syncSchemas(); (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; - engine.registry.installPackage(BASE as any, 'marketplace'); + engine.registry.installPackage(BASE as any, 'code'); + (engine as unknown as { manifests: Map }).manifests.set(BASE.id, BASE); engine.registry.installPackage(EXT as any, 'code'); engine.registry.installPackage(OTHER as any, 'code'); engine.registry.installPackage(SEALED as any, 'marketplace'); From 6c336babc7802b39b5699b18b38afeb612f54d99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:28:28 +0000 Subject: [PATCH 03/10] fix(service-automation): read an absent objectql as no registry, the plugin's other optional reads' way Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- .../services/service-automation/src/plugin.ts | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index a7c51c5c321..1d4f72bd940 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -581,6 +581,23 @@ export function nonHostDeclaringPackageOf( return declaresSameProviderBinding(entry, declaration, stamp) ? undefined : { packageId: stamp, why: 'stored' }; } +/** + * The package registry ObjectQL serves, read where it is used, or `undefined` + * when the host serves no ObjectQL: the installed manifests the customization + * pair lookup reads (`installedManifestsOf`). Without one, no package is + * installed, and every package is its own App. Absent is read the way this + * plugin's other optional `objectql` reads it (`flowHistoryReader`, the + * suspended-run store): a `getService` that throws is no service, whichever + * kernel spelled the refusal. + */ +function objectqlRegistryOf(ctx: PluginContext): InstalledPackageSource | undefined { + try { + return ctx.getService<{ registry?: InstalledPackageSource }>('objectql')?.registry; + } catch { + return undefined; + } +} + /** * [#28713] Whether the kernel this plugin runs on dispatches its boot-phase * hooks (`kernel:bootstrapped` among them) once every plugin has started — @@ -592,22 +609,6 @@ export function nonHostDeclaringPackageOf( * `start()` reconcile keeps every stored entry, so none waits for a pass that * never comes. */ -/** - * The package registry ObjectQL serves, read where it is used, or `undefined` - * when no ObjectQL service is registered: the installed manifests the - * customization pair lookup reads (`installedManifestsOf`). Without one, no - * package is installed, and every package is its own App. - */ -function objectqlRegistryOf(ctx: PluginContext): InstalledPackageSource | undefined { - try { - return ctx.getService<{ registry?: InstalledPackageSource }>('objectql')?.registry; - } catch (error) { - // `getService` refuses a service no plugin registered: no registry. - if (isServiceNotRegisteredError(error)) return undefined; - throw error; - } -} - function kernelDispatchesBootPhaseHooks(ctx: PluginContext): boolean { let kernel: unknown; try { From a53723887d0a84791e4fc6491b5edeaa3e481af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:23:01 +0000 Subject: [PATCH 04/10] fix(runtime)!: a kernel-shipped permission set's copy lands environment-wide, the kernel having no pair Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- packages/metadata-protocol/src/index.ts | 2 +- .../src/protocol-customization-package.ts | 13 +++++++ .../permission-set-clone-door.dogfood.test.ts | 15 +++++--- .../src/domains/permission-set-clone.test.ts | 35 ++++++++++++++++--- .../src/domains/permission-set-clone.ts | 30 ++++++++++++++-- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 7a2f271328d..a42e23443c5 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -272,7 +272,7 @@ export { unmanagedInstallPostureRefusal } from './package-unmanaged-install.js'; // install primitive and the duplicate ask it; exported so `POST /packages` // (`@objectstack/runtime`) asks the same one ahead of both its install limbs, // its no-protocol fallback included, instead of keeping a second copy. -export { customizationPairManifestRefusal } from './protocol-customization-package.js'; +export { customizationPairManifestRefusal, kernelPackageScope } from './protocol-customization-package.js'; // [ADR-0029 D7] The refusal of `navigationContributions` on a package created // at runtime, whose install never registers them. The install primitive asks diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts index 97707685edd..9cf7d44472f 100644 --- a/packages/metadata-protocol/src/protocol-customization-package.ts +++ b/packages/metadata-protocol/src/protocol-customization-package.ts @@ -165,6 +165,19 @@ function kernelPackageScopeOf(row: PairPackageRow | undefined): string | undefin return typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope) ? scope : undefined; } +/** + * [ruling record 6691217245] The kernel scope of `packageId` as `engine`'s + * registry holds it ({@link kernelPackageScopeOf}), or `undefined` for a + * package that is not a kernel package or is not installed. The door's own + * reading, for the callers that land something in a package's pair and must + * know beforehand that a kernel package has none (the "new similar" doors). + */ +export function kernelPackageScope(engine: unknown, packageId: string): string | undefined { + const registry = (engine as { registry?: Partial } | null | undefined)?.registry; + if (!registry || typeof registry.getPackage !== 'function') return undefined; + return kernelPackageScopeOf(registry.getPackage(packageId)); +} + /** * The calls in flight, per registry and per pair id — see the module header, * "Idempotent, and one pair under concurrency". Keyed weakly by the registry, diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index 1efe4115504..255ae242b3a 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -228,7 +228,11 @@ describe('the permission-set clone door, on a booted showcase', () => { } }); - it('a platform set (the lock-and-clone rule\'s commonest source) copies into its shipping package\'s pair', async () => { + // [#29594, ruling record 6691217245] A kernel package has no paired + // customization package, so a platform set's copy is saved where an + // environment-authored set's copy is: environment-wide, the notice naming + // the kernel package and `package`. + it('a platform set (the lock-and-clone rule\'s commonest source) copies environment-wide: the kernel has no pair', async () => { const reader = securityCatalogReaderOf(ql)!; const source = await reader.resolve('permission', 'member_default'); expect(source?.packageId, 'member_default is not packaged on this boot').toBeTruthy(); @@ -236,9 +240,12 @@ describe('the permission-set clone door, on a booted showcase', () => { const res = await clone(token, 'member_default', { name: 'my_member_baseline', label: 'My Member Baseline' }); const text = await res.clone().text(); expect(res.status, text).toBe(200); - const landed = ((await res.json()) as any).data.package; - expect(landed).toBe(`${source!.packageId}.custom`); - expect((await reader.resolve('permission', 'my_member_baseline'))?.packageId).toBe(landed); + const data = ((await res.json()) as any).data; + expect(data.package).toBeNull(); + expect(data.noticeCode).toBe('PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE'); + expect(data.notice).toContain(`kernel package '${source!.packageId}'`); + expect((await reader.resolve('permission', 'my_member_baseline'))?.packageId).toBeUndefined(); + expect((await stack.apiAs(token, 'GET', `/packages/${source!.packageId}.custom`)).status).toBe(404); }); it('CONTROL — an environment-authored source still copies environment-wide, with no package', async () => { diff --git a/packages/runtime/src/domains/permission-set-clone.test.ts b/packages/runtime/src/domains/permission-set-clone.test.ts index 63d307abd73..eed0d60aaa7 100644 --- a/packages/runtime/src/domains/permission-set-clone.test.ts +++ b/packages/runtime/src/domains/permission-set-clone.test.ts @@ -67,7 +67,10 @@ const SYS_ONLY_SET = { name: 'sys_only', label: 'Sys Only', objects: { sys_user: /** A set `crm` ships that grants on a platform object only: its package is the one thing out of an island's reach. */ const CRM_SYS_SET = { name: 'crm_user_reader', label: 'CRM User Reader', objects: { sys_user: { allowRead: true } }, _packageId: 'crm' }; -const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET, OWNED_SET, SYS_ONLY_SET, CRM_SYS_SET] }; +/** [#29594] A set the platform's kernel ships (scope `system`): the commonest lock-and-clone source. */ +const KERNEL = 'com.objectstack.plugin-security'; +const KERNEL_SET = { name: 'member_default', label: 'Member', objects: { account: { allowRead: true } }, _packageId: KERNEL, _lock: 'full' }; +const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET, OWNED_SET, SYS_ONLY_SET, CRM_SYS_SET, KERNEL_SET] }; /** * The package registry the door reads: `crm` is a booted CODE package @@ -82,6 +85,7 @@ const PACKAGES: Record }> = { 'com.acme.ext': { manifest: { id: 'com.acme.ext', dependencies: { crm: '^1' } } }, 'com.acme.island': { manifest: { id: 'com.acme.island' } }, 'com.vendor.app': { manifest: { id: 'com.vendor.app', scope: 'cloud' } }, + [KERNEL]: { manifest: { id: KERNEL, namespace: 'sys', version: '18.3.0', scope: 'system' } }, }; interface Saved { type: string; name: string; item: Record; parentVersion?: string | null; packageId?: string; inheritedGrantObjects?: readonly string[] } @@ -238,6 +242,22 @@ describe('the permission-set clone door — success', () => { expect((res as any).response.body.data.notice).toBe(permissionSetCloneNotice({ packageId: null, created: false })); }); + it('[#29594] a set a kernel package ships copies environment-wide, with no ensure: the kernel has no pair', async () => { + const h = boot(); + + const res = await clone(h, HOLDER(), KERNEL_SET.name, { name: 'my_member', label: 'My Member' }); + + expect(statusOf(res)).toBe(200); + expect(h.ensures).toEqual([]); + expect(h.saves[0]).not.toHaveProperty('packageId'); + expect(h.saves[0].item).toEqual({ name: 'my_member', label: 'My Member', objects: KERNEL_SET.objects }); + const data = (res as any).response.body.data; + expect(data.package).toBeNull(); + expect(data.noticeCode).toBe('PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE'); + expect(data.notice).toBe(permissionSetCloneNotice({ packageId: null, created: false, kernelSource: KERNEL })); + expect(data.notice).toContain(`kernel package '${KERNEL}'`); + }); + it('a set already in a writable package copies into that package, with no ensure', async () => { const h = boot(); @@ -331,7 +351,7 @@ describe('the permission-set clone door — authority', () => { }); describe('[#29727] the clone notice is named by one stable code per landing', () => { - // CONTROL: the three sentences, spelled out as they were served before the + // CONTROL: the sentences (the fourth since #29594), spelled out as they were served before the // codes arrived, so a change to `notice` while adding a code reds here. const TAIL = 'The copy is a new permission set authored in this system, with no link to the set it was copied ' @@ -344,6 +364,13 @@ describe('[#29727] the clone notice is named by one stable code per landing', () code: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE', notice: 'The copy was saved environment-wide, in no package, as the set it was copied from is. ' + TAIL, }, + { + landing: { packageId: null, created: false, kernelSource: 'com.objectstack.plugin-security' }, + code: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE', + notice: "The copy was saved environment-wide, in no package: the set it was copied from is shipped by kernel " + + "package 'com.objectstack.plugin-security', which has no paired customization package in this version. " + + 'Name a writable package with `package` to keep the copy in it instead. ' + TAIL, + }, { landing: { packageId: 'com.acme.base', created: false }, code: 'PERMISSION_SET_CLONE_IN_PACKAGE', @@ -363,9 +390,9 @@ describe('[#29727] the clone notice is named by one stable code per landing', () expect(permissionSetCloneNotice(landing)).toBe(notice); }); - it('the three codes are distinct and the table holds exactly them', () => { + it('the four codes are distinct and the table holds exactly them', () => { expect(Object.values(PERMISSION_SET_CLONE_NOTICE_CODES).sort()).toEqual(VARIANTS.map((v) => v.code).sort()); - expect(new Set(VARIANTS.map((v) => permissionSetCloneNotice(v.landing))).size).toBe(3); + expect(new Set(VARIANTS.map((v) => permissionSetCloneNotice(v.landing))).size).toBe(4); }); }); diff --git a/packages/runtime/src/domains/permission-set-clone.ts b/packages/runtime/src/domains/permission-set-clone.ts index a46dc08a5e4..923ccfc2ebe 100644 --- a/packages/runtime/src/domains/permission-set-clone.ts +++ b/packages/runtime/src/domains/permission-set-clone.ts @@ -74,6 +74,13 @@ * grants on; the one check both "new similar" doors ask * (`newSimilarTargetRefusal`, `./new-similar-target.ts`), else refused with * nothing written; + * - **omitted, the source shipped by a kernel package** (scope `system` / + * `cloud`: the platform's own sets, `member_default` among them) — the + * save is environment-wide, as for a source authored in the environment: + * a kernel package has no paired customization package in this version + * (ruling record 6691217245), and the copy is a new set, not a change to + * the kernel's. The notice says why, and that `package` keeps the copy in + * a package the caller names; * - **omitted, the source shipped by a read-only package X** — X's paired * customization package `.custom`, answered by N-13's ensure door * (`ensureCustomizationPackage`, `@objectstack/metadata-protocol`), the @@ -110,7 +117,7 @@ import { SnakeCaseIdentifierSchema } from '@objectstack/spec/shared'; import { metaWriteCapabilityVerdict, walledCatalogWriteVerdict } from '@objectstack/metadata-core'; import { readEffectiveTenancyPosture } from './activation-gate.js'; import { securityCatalogReaderOf, type SecurityCatalogEntry } from '@objectstack/core'; -import { isWritablePackage, type ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { isWritablePackage, kernelPackageScope, type ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { newSimilarTargetRefusal, newSimilarTargetRefusalAnswer } from './new-similar-target.js'; import { validationFailure } from '../validation-failure.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -158,6 +165,11 @@ export const PERMISSION_SET_CLONE_NOTICE = export interface PermissionSetCloneLanding { readonly packageId: string | null; readonly created: boolean; + /** + * The kernel package that shipped the source, when the copy went + * environment-wide because that package has no pair; absent otherwise. + */ + readonly kernelSource?: string; } /** @@ -173,16 +185,17 @@ export interface PermissionSetCloneLanding { */ export const PERMISSION_SET_CLONE_NOTICE_CODES = Object.freeze({ environmentWide: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE', + environmentWideKernelSource: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE', inPackage: 'PERMISSION_SET_CLONE_IN_PACKAGE', inCreatedPackage: 'PERMISSION_SET_CLONE_IN_CREATED_PACKAGE', } as const); -/** Which of the three notices a landing is told with. */ +/** Which of the four notices a landing is told with. */ export type PermissionSetCloneLandingKind = keyof typeof PERMISSION_SET_CLONE_NOTICE_CODES; /** The one place a landing is sorted into its notice variant. */ export function permissionSetCloneLandingKind(landing: PermissionSetCloneLanding): PermissionSetCloneLandingKind { - if (landing.packageId === null) return 'environmentWide'; + if (landing.packageId === null) return landing.kernelSource !== undefined ? 'environmentWideKernelSource' : 'environmentWide'; return landing.created ? 'inCreatedPackage' : 'inPackage'; } @@ -196,6 +209,12 @@ export function permissionSetCloneNotice(landing: PermissionSetCloneLanding): st return 'The copy was saved environment-wide, in no package, as the set it was copied from is. ' + PERMISSION_SET_CLONE_NOTICE; } + if (kind === 'environmentWideKernelSource') { + return `The copy was saved environment-wide, in no package: the set it was copied from is shipped by kernel ` + + `package '${landing.kernelSource}', which has no paired customization package in this version. Name a ` + + 'writable package with `package` to keep the copy in it instead. ' + + PERMISSION_SET_CLONE_NOTICE; + } return `The copy was saved in package '${landing.packageId}'` + (kind === 'inCreatedPackage' ? ', created for it to hold your changes to the package the set came from' : '') + ', beside the original rather than in place of it, and travels with that package. ' @@ -297,6 +316,11 @@ export async function resolveDefaultCloneLanding( const sourcePackageId = source.packageId; if (sourcePackageId === undefined) return { packageId: null, created: false }; if (isWritablePackage(ql, sourcePackageId)) return { packageId: sourcePackageId, created: false }; + // [ruling record 6691217245] A kernel package has no pair: the copy of + // its set is a new set, saved where an environment-authored set's is. + if (kernelPackageScope(ql, sourcePackageId) !== undefined) { + return { packageId: null, created: false, kernelSource: sourcePackageId }; + } if (typeof protocol.ensureCustomizationPackage !== 'function') { throw Object.assign( new Error( From 021a8a3d36acd744eb49407ab9e449195488cb0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:23:21 +0000 Subject: [PATCH 05/10] docs(changeset): the platform permission-set clone landing Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- .changeset/29594-pair-lookup-installed-kernel-verdict.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/29594-pair-lookup-installed-kernel-verdict.md b/.changeset/29594-pair-lookup-installed-kernel-verdict.md index 7f423034d60..d4bafff2e42 100644 --- a/.changeset/29594-pair-lookup-installed-kernel-verdict.md +++ b/.changeset/29594-pair-lookup-installed-kernel-verdict.md @@ -22,6 +22,7 @@ Metadata-protocol: none - **Why (ruling records 6691022506 and 6691217245 on ADR-0146 D3).** The customization unit is the App, and the kernel is not an App a customer owns: it ships with the runtime at the runtime's version and is always installed whole. In this version no kernel object is customizable, so the door creates no pair for one. Which kernel objects a later version opens (a whitelist) is that version's decision. - **What the door answers.** `409 RESOURCE_CONFLICT`, the status and code the door already answers when a base's state rules the pair out (a writable base, no namespace, a version that is not SemVer). Nothing is created or written. Every other base is answered exactly as before. - **A kernel pair that already exists stays.** An earlier version of the door may have created `.custom` on a deployment. It stays installed, the overrides inside it stay served by every read of the kernel item's name, and a further save into it is still admitted. Only the ensure call is refused. +- **A copy of a platform permission set.** `POST /api/v1/security/permission-sets/:name/clone` with no `package` named landed the copy of a kernel-shipped set (`member_default` and the platform's other sets) in `.custom`. It now saves the copy environment-wide, as it already does for a set authored in the environment, with the notice code `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE` and a sentence naming the kernel package and `package`. A Console that does not know the new code shows that sentence as sent. Naming a writable package with `package` still lands the copy there. The flow clone door answers a kernel-shipped flow with the ensure door's sentence. - **The closes that named the pair.** A package-less save, or a save naming the kernel package, onto a kernel item was refused naming the pair as the fix. That fix no longer exists, so those two closes leave the save open, as they already do for every other base the ensure door refuses. - **The lookup.** Which pair customizes a package is asked through the installed manifests of the registry the caller serves (`installedManifestsOf(registry)`). Today the answer is unchanged for every package: `appOf` is still the identity. The argument is threaded through every site now so that resolving a module to its App is one change in `appOf`. - **`os validate`'s customization-namespace advisory** now skips a package that is itself a pair through `isCustomizationPackage`, the one spelling of that question. An id ending in `.custom` that no base derives (`a.custom.custom`, a bare `.custom`) is now judged like any other package. From 1340933c2995e8c08205a260ac86ec7174b9a2e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 23:12:47 +0000 Subject: [PATCH 06/10] test(#29594): pay the lazy imports' first load at collection in the edited test files (check:test-source-alias) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- .../src/protocol.packaged-base-refusal.test.ts | 2 ++ .../src/domains/automation-flow-clone-custom-package.test.ts | 2 ++ .../runtime/src/domains/packages-ensure-customization.test.ts | 2 ++ packages/runtime/src/domains/permission-set-clone.test.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index f491e5736bb..55851d647e4 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -26,6 +26,8 @@ * provenance). `@objectstack/objectql` cannot be imported here: it depends on * this package. */ +// Pays the lint root's first load at collection, never inside a clocked case. +import './lint-root-preload.testkit.js'; import { afterEach, describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, installedManifestsOf } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY, OVERRIDE_TYPE_CLASSIFICATION } from '@objectstack/spec/kernel'; diff --git a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts index e9054b535a8..1e893784604 100644 --- a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts +++ b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts @@ -33,6 +33,8 @@ * the store, and the named package are asserted — never a refusal's sentence. */ +// Pays `@objectstack/metadata`'s first load at collection, never inside a clocked case. +import '@objectstack/metadata'; import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; diff --git a/packages/runtime/src/domains/packages-ensure-customization.test.ts b/packages/runtime/src/domains/packages-ensure-customization.test.ts index c89aae30e10..ed57f83cb94 100644 --- a/packages/runtime/src/domains/packages-ensure-customization.test.ts +++ b/packages/runtime/src/domains/packages-ensure-customization.test.ts @@ -22,6 +22,8 @@ * really install. */ +// Pays `@objectstack/metadata`'s first load at collection, never inside a clocked case. +import '@objectstack/metadata'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; diff --git a/packages/runtime/src/domains/permission-set-clone.test.ts b/packages/runtime/src/domains/permission-set-clone.test.ts index eed0d60aaa7..9a23ebacf91 100644 --- a/packages/runtime/src/domains/permission-set-clone.test.ts +++ b/packages/runtime/src/domains/permission-set-clone.test.ts @@ -21,6 +21,8 @@ // there; a given target is judged by the shared new-similar check; an // environment-authored source is unchanged. +// Pays `@objectstack/metadata`'s first load at collection, never inside a clocked case. +import '@objectstack/metadata'; import { describe, it, expect } from 'vitest'; import { HttpDispatcher } from '../http-dispatcher.js'; From ee5587b408d451fe8bc52c83d9b630d4f15852d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 23:23:38 +0000 Subject: [PATCH 07/10] docs(#29594): the ensure door refuses a kernel package; a platform set's copy lands environment-wide Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- content/docs/api/metadata-api.mdx | 4 +++- content/docs/permissions/permission-sets.mdx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 3e7b62b00cf..f3880d37198 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -111,7 +111,9 @@ capability changes only with a new version of the package that declares it. An sentence names that type's own route. What the reads then serve is in [Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146). `POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls -it on the first change), and `GET /packages/hotcrm/customizations` lists its overrides, +it on the first change); a kernel package (scope `system` or `cloud`) has no pair in this +version and answers `409 RESOURCE_CONFLICT`, though a pair an earlier version created keeps +serving what it holds. `GET /packages/hotcrm/customizations` lists its overrides, extensions and switched-off items in one read, beside `contributions`: the fields (kind `field`) and flows (kind `flow`) other installed packages contribute to `hotcrm`'s objects, and the navigation items (kind `nav`) they contribute to `hotcrm`'s apps, each entry naming its package. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 112c703ff22..c8472c0d4c8 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -453,7 +453,11 @@ the source never reach the copy. package `.custom`, the package that holds everything you changed about X. The pair is created on the first copy, by the same ensure door as `POST /api/v1/packages/:id/ensure-customization` (its only creation path), - and reused after. A copy of a set already in a writable package is + and reused after. A copy of a set the platform itself ships (a kernel + package of scope `system` or `cloud`, such as `member_default`) is saved + environment-wide, in no package: a kernel package has no paired + customization package in this version, and the notice says so + (`noticeCode` `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`). A copy of a set already in a writable package is saved in that package, and a copy of a set authored in this system stays environment-wide, in no package. With `package`, the copy is saved there: it must be an installed, writable package (else `422` From 3ddfa1c721032f704caa4fe260c1401c8b7fce71 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 23:42:09 +0000 Subject: [PATCH 08/10] fix(#29594): the overlay-write closes keep refusing a kernel item, naming no pair Ruling record 6691217245 serves every kernel object as not customizable in this version, so the package-less close and the close of a write naming the base stay shut on a kernel item exactly where they were shut before the ensure door's kernel verdict: 403 NOT_OVERRIDABLE, with a sentence that names no pair. The ensure door's other judgements are read with its kernel verdict left out (judgeKernel: false), and the fix carries kernelScope. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- .../src/protocol-customization-package.ts | 16 +++- .../src/protocol-packaged-base-refusal.ts | 82 +++++++++++++++++-- .../protocol-packageless-overlay-migration.ts | 11 ++- ...col-custom-override-effective-read.test.ts | 24 ++++-- 4 files changed, 110 insertions(+), 23 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts index 9cf7d44472f..be49aae3c8c 100644 --- a/packages/metadata-protocol/src/protocol-customization-package.ts +++ b/packages/metadata-protocol/src/protocol-customization-package.ts @@ -173,9 +173,8 @@ function kernelPackageScopeOf(row: PairPackageRow | undefined): string | undefin * know beforehand that a kernel package has none (the "new similar" doors). */ export function kernelPackageScope(engine: unknown, packageId: string): string | undefined { - const registry = (engine as { registry?: Partial } | null | undefined)?.registry; - if (!registry || typeof registry.getPackage !== 'function') return undefined; - return kernelPackageScopeOf(registry.getPackage(packageId)); + const manifest = installedManifestsOfEngine(engine)(packageId); + return kernelPackageScopeOf(manifest === undefined ? undefined : { manifest }); } /** @@ -338,6 +337,15 @@ export function customizationPairEnsureRefusal( registry: PairRegistry, baseId: string, pairId: string, + options: { + /** + * `false` asks every other judgement of the door with the kernel + * verdict left out: the overlay-write closes read it so that a + * kernel item stays closed exactly where it was closed before the + * verdict (`overlayPairFixOf`). The door itself always judges it. + */ + readonly judgeKernel?: boolean; + } = {}, ): Error | undefined { const base = registry.getPackage(baseId); if (!base) { @@ -350,7 +358,7 @@ export function customizationPairEnsureRefusal( // version of this door created for a kernel package stays installed, and // the overrides inside it stay served and writable (the override gate and // the served-override read never ask this door). - const kernelScope = kernelPackageScopeOf(base); + const kernelScope = options.judgeKernel === false ? undefined : kernelPackageScopeOf(base); if (kernelScope !== undefined) { return refusal(409, `Package '${baseId}' is a kernel package (scope '${kernelScope}'): kernel objects are not ` + `customizable in this version, so '${baseId}' has no paired customization package and '${pairId}' is ` diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 402367e86d7..2e24bfb8453 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -45,7 +45,7 @@ import { } from './packaged-base-regime.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js'; -import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js'; +import { customizationPairEnsureRefusal, kernelPackageScope, type PairRegistry } from './protocol-customization-package.js'; import { resolveOverlaySchema } from './protocol-meta-type-schema.js'; import { resolvePackageClosure } from '@objectstack/lint/runtime'; import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; @@ -263,9 +263,12 @@ export type PackagelessWriteOperation = 'save' | 'publish' | 'delete' | 'rollbac */ export function packagelessOverlayWriteRefusal( request: { type: string; name: string }, - closed: { base: string; pair: string }, + closed: OverlayWriteClose, operation: PackagelessWriteOperation, ): Error { + if (closed.kernelScope !== undefined) { + return kernelOverlayWriteRefusal(request, closed.base, closed.kernelScope, 'packageless', operation); + } const { item, shipped, pair, declared, writeInto, ceremony } = closedOverlayWriteClauses(request, closed); const rule = `A write naming no package is closed where ${ceremony} can move its row ` + `(ADR-0146 D11); a package-less row stored before it is served until that command moves it into '${closed.pair}'.`; @@ -310,6 +313,37 @@ function closedOverlayWriteClauses( return { item, shipped, pair, declared, writeInto, ceremony, boundRowServed }; } +/** + * [#29594, ruling record 6691217245] Both closes' refusal of a write onto an + * item a kernel package ships ({@link OverlayWriteClose.kernelScope}). Kernel + * objects are not customizable in this version and the kernel has no pair, so + * the sentence names no pair and no fix: the write stays closed (ADR-0146 D11) + * where it was closed before the kernel verdict, under the same + * `403 NOT_OVERRIDABLE`, until a later version's kernel whitelist opens the + * item. A row stored before the close stays served; a row bound to the base + * is still removed by a delete naming it. + */ +function kernelOverlayWriteRefusal( + request: { type: string; name: string }, + base: string, + kernelScope: string, + address: 'packageless' | 'named_base', + operation: PackagelessWriteOperation, +): Error { + const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const item = `'${type}/${request.name}' ships in kernel package '${base}' (scope '${kernelScope}')`; + const write = address === 'packageless' + ? (operation === 'delete' || operation === 'rollback' + ? `a ${operation} of its package-less row` + : 'a write naming no package') + : (operation === 'rollback' ? `a rollback of its row bound to '${base}'` : `a write naming '${base}'`); + const stored = address === 'packageless' + ? 'A package-less row stored before the close stays served.' + : `A row bound to '${base}' stored before the close stays served until a delete naming '${base}' removes it.`; + return closedOverlayWriteError(`${item}: kernel objects are not customizable in this version, so ${write} is ` + + `refused (ADR-0146 D3, D11). ${stored}`); +} + /** `403 NOT_OVERRIDABLE`, the code and status the sealed write already answers, so neither close gains a code. */ function closedOverlayWriteError(message: string): Error { const err = new Error(message); @@ -357,9 +391,12 @@ export type NamedBaseWriteOperation = Exclude registry.getPackage(id) as { manifest?: unknown } | undefined); const dependencies = [base, ...(baseClosure?.dependencies ?? []).filter((id) => id !== pair)]; - return { base, pair, basedOn, pendingPairScope: { packageId: pair, dependencies } }; + return { base, pair, basedOn, pendingPairScope: { packageId: pair, dependencies }, ...kernel }; } /** @@ -589,7 +653,7 @@ export interface NamedBaseWriteSubject { export function namedBaseCloseSubject( this: NamedBaseCloseHost, request: NamedBaseWriteSubject, -): { base: string; pair: string } | null { +): OverlayWriteClose | null { const base = typeof request.packageId === 'string' && request.packageId.length > 0 ? request.packageId : undefined; if (base === undefined) return null; // the package-less write: #28576's close // A body carrying `overrides` keeps its answer: the D2 gate refuses a @@ -607,7 +671,7 @@ export function namedBaseCloseSubject( ? overlayMemberOfBody(type, written) : overlayMemberValue(type, request.member); if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body: written, member })) return null; - return { base, pair: fix.pair }; + return fix.kernelScope === undefined ? { base, pair: fix.pair } : { base, pair: fix.pair, kernelScope: fix.kernelScope }; } // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index 84471a65a59..f6457a8107f 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -144,6 +144,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { overlayPairFixAdmits, overlayPairFixOf, + type OverlayWriteClose, overrideDeclarationRefusedByShape, overrideWriteMemberOf, type OverrideWriteVerdictAsk, @@ -756,7 +757,11 @@ export interface PackagelessWriteSubject { * locale the base does not ship is refused there (#28777) and stays open: * the ceremony moves such a row as the pair's own member, declaring * nothing (#28904), and the close reaches only the declared-override - * moves, so its package-less write is the one it had before the close. + * moves, so its package-less write is the one it had before the close; + * - [#29594] an item a kernel package ships stays closed where it was + * before the kernel verdict, though the ensure door creates no pair for + * it: the subject carries the base's `kernelScope`, and the refusal says + * kernel objects are not customizable in this version, naming no pair. * * Registry reads first; the one store read (the item's rows) only for a name * the registry has not already left open. @@ -764,7 +769,7 @@ export interface PackagelessWriteSubject { export async function packagelessCloseSubject( this: PackagelessCloseHost, request: PackagelessWriteSubject, -): Promise<{ base: string; pair: string } | null> { +): Promise { if (typeof request.packageId === 'string' && request.packageId.length > 0) return null; const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; const shippers = this.overlayBaseShippers(request.type, request.name); @@ -803,5 +808,5 @@ export async function packagelessCloseSubject( // Step 2's gate: the declared body, into the pair. if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body, member })) return null; - return { base, pair: fix.pair }; + return fix.kernelScope === undefined ? { base, pair: fix.pair } : { base, pair: fix.pair, kernelScope: fix.kernelScope }; } diff --git a/packages/objectql/src/protocol-custom-override-effective-read.test.ts b/packages/objectql/src/protocol-custom-override-effective-read.test.ts index 9e7b0e88462..cc696f96d8b 100644 --- a/packages/objectql/src/protocol-custom-override-effective-read.test.ts +++ b/packages/objectql/src/protocol-custom-override-effective-read.test.ts @@ -470,7 +470,8 @@ describe('[#28409] a same-named item outside X\'s closure is served under its ow * `.custom` with overrides in it. The verdict refuses the ENSURE call * only: the pair stays installed, the overrides in it stay served by every * read of the base's name, and a further override saved into it is admitted - * by the write gate, which never asks the ensure door. + * by the write gate, which never asks the ensure door. The overlay-write + * closes (ADR-0146 D11) stay shut on a kernel item, naming no pair. */ describe('[#29594] a kernel base\'s pair an earlier version created keeps serving its overrides', () => { it('the ensure call is refused 409; the override already in the pair is served, and another save into it lands', async () => { @@ -496,7 +497,7 @@ describe('[#29594] a kernel base\'s pair an earlier version created keeps servin expect(columnsOf(await read(protocol))).toEqual(BASE_COLUMNS.slice(0, 3)); }); - it('the closes that named the pair leave a kernel item\'s save open: there is no pair to send it to', async () => { + it('the closes keep refusing a kernel item\'s save, naming no pair: kernel objects are not customizable in this version', async () => { const body = listBody(UNBOUND_COLUMNS); // Control: an App's item — the package-less save and the save naming the base are closed onto the pair. const app = await boot(); @@ -505,11 +506,20 @@ describe('[#29594] a kernel base\'s pair an earlier version created keeps servin await expect(app.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId: BASE } as any)) .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, message: expect.stringContaining(PAIR) }); - // A kernel item: the ensure door creates no pair, so neither close names one. + // A kernel item: still closed, 403 as before the kernel verdict, but + // the ensure door creates no pair for it, so neither sentence names one. const kernel = await boot(baseManifest({ scope: 'system' })); - await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any); - expect(kernel.rowsOf(VIEW).map((r) => r.package_id ?? null)).toEqual([null]); - await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId: BASE } as any); - expect(kernel.rowsOf(VIEW).map((r) => r.package_id ?? null)).toEqual([null, BASE]); + const packageless = await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft' } as any) + .then(() => null, (error: any) => error); + expect(packageless).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(packageless.message).toContain(`ships in kernel package '${BASE}' (scope 'system')`); + expect(packageless.message).toContain('kernel objects are not customizable in this version, so a write naming no package is refused'); + expect(packageless.message).not.toContain(PAIR); + const named = await kernel.protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId: BASE } as any) + .then(() => null, (error: any) => error); + expect(named).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(named.message).toContain(`kernel objects are not customizable in this version, so a write naming '${BASE}' is refused`); + expect(named.message).not.toContain(PAIR); + expect(kernel.rowsOf(VIEW)).toEqual([]); }); }); From 38ddd86702ba38be6baf0f92870fa6e98aba2c97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 23:43:42 +0000 Subject: [PATCH 09/10] test(#29594): a copy of a kernel-shipped flow with no package named answers the ensure door's 409 The flow clone door lands the copy in the source's pair through the ensure door; a kernel package has none in this version, so the door relays the 409 and the registration made before the landing is undone: no copy in the engine, no row, no pair. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- ...tomation-flow-clone-custom-package.test.ts | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts index 1e893784604..9f317616b1d 100644 --- a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts +++ b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts @@ -110,7 +110,7 @@ interface Answer { * protocol refuses (an illegal machine name among it) throws the way the real * engine throws. The default rig's engine is a map that accepts anything. */ -async function boot(options: { parse?: boolean } = {}) { +async function boot(options: { parse?: boolean; baseScope?: 'system' | 'cloud' } = {}) { const engine = new ObjectQL(); liveEngines.push(engine); engine.registerDriver(new SqlDriver({ @@ -124,8 +124,9 @@ async function boot(options: { parse?: boolean } = {}) { engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); await engine.syncSchemas(); (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; - engine.registry.installPackage(BASE as any, 'code'); - (engine as unknown as { manifests: Map }).manifests.set(BASE.id, BASE); + const base = options.baseScope === undefined ? BASE : { ...BASE, scope: options.baseScope }; + engine.registry.installPackage(base as any, 'code'); + (engine as unknown as { manifests: Map }).manifests.set(BASE.id, base); engine.registry.installPackage(EXT as any, 'code'); engine.registry.installPackage(OTHER as any, 'code'); engine.registry.installPackage(SEALED as any, 'marketplace'); @@ -259,6 +260,35 @@ describe('[#28149] the default target: the copy lands in .custom, switched }, 60_000); }); +/** + * [#29594, ruling record 6691217245] A kernel package (scope `system` / + * `cloud`) has no pair in this version: the ensure door refuses it 409. The + * flow door's default landing IS the ensure door, so a copy of a kernel-shipped + * flow with no `package` named answers that refusal as relayed, and the + * registration made before the landing is undone (`restoreFlowRegistration`): + * the engine holds no copy, nothing is stored, and no pair is created. + */ +describe('[#29594] a kernel-shipped flow: no pair to land the copy in', () => { + for (const baseScope of ['system', 'cloud'] as const) { + it(`scope '${baseScope}', no \`package\` named: 409 with the ensure door's sentence, and the engine holds no copy`, async () => { + const rig = await boot({ baseScope }); + const before = [...rig.flows.keys()].sort(); + + const answer = await rig.clone(SOURCE, { name: 'acme_lead_followup', label: 'Lead follow-up (ACME)' }); + + expect(answer.status, JSON.stringify(answer.body)).toBe(409); + expect(answer.code).toBe('RESOURCE_CONFLICT'); + expect(JSON.stringify(answer.body)).toContain(`Package '${BASE.id}' is a kernel package (scope '${baseScope}')`); + expect(rig.flows.has('acme_lead_followup')).toBe(false); + expect([...rig.flows.keys()].sort()).toEqual(before); + expect(await rig.rows('acme_lead_followup')).toEqual([]); + expect(rig.installed()).not.toContain(PAIR); + // The original keeps running, untouched. + expect(rig.flows.get(SOURCE)?.status).toBe('active'); + }, 60_000); + } +}); + describe('[#29650, ADR-0146 D2/D4] New similar while the source\'s override is live', () => { /** * Customize the base's flow the way Studio does: ensure the pair, save the From b94d1ac68d151d99ad908992ad25f65bf2413a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 23:45:15 +0000 Subject: [PATCH 10/10] docs(#29594): declare both arms, keep the kernel closes in the changeset, and say where a kernel copy lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset's Clause-② line names both arms: the flow clone's 200 to 409 for a kernel-shipped flow beside the ensure door, and the five new exports and the new permission-set clone notice code. The closes bullet now says they stay shut on a kernel item. Who moves names a vendor package that declares a platform scope. metadata-lifecycle.mdx says a kernel item's package-less write names no pair, a kernel flow's copy answers 409 until package is named, and a kernel set's copy lands environment-wide. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8 --- .changeset/29594-pair-lookup-installed-kernel-verdict.md | 8 ++++---- content/docs/concepts/metadata-lifecycle.mdx | 6 +++--- .../src/protocol-customization-package.ts | 3 ++- .../src/domains/packages-ensure-customization.test.ts | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.changeset/29594-pair-lookup-installed-kernel-verdict.md b/.changeset/29594-pair-lookup-installed-kernel-verdict.md index d4bafff2e42..5dd1dadc139 100644 --- a/.changeset/29594-pair-lookup-installed-kernel-verdict.md +++ b/.changeset/29594-pair-lookup-installed-kernel-verdict.md @@ -12,18 +12,18 @@ feat(metadata-protocol, metadata-core)!: the ensure door creates no customization pair for a kernel package, and every pair question carries the installed manifests -Clause-②: yes (narrowing) — `POST /api/v1/packages/:id/ensure-customization` (and the protocol's `ensureCustomizationPackage`) answers a kernel base, a package of scope `system` or `cloud`, with `409 RESOURCE_CONFLICT` and a sentence saying kernel objects are not customizable in this version, where it created `.custom`. Beside it, the exported `pairOf`, `isPairOf` and `appOf` (`@objectstack/metadata-core`), `isServedOverrideRowOf` and the `installed` member of `OverrideWriteClosure` (`@objectstack/metadata-protocol`), and `resolveFlowPrecedence` / `describeFlowContender` (`@objectstack/service-automation`) take the installed-manifest lookup as a required argument. +Clause-②: yes (narrowing) — both arms. Narrowing: `POST /api/v1/packages/:id/ensure-customization` (and the protocol's `ensureCustomizationPackage`) answers a kernel base, a package of scope `system` or `cloud`, with `409 RESOURCE_CONFLICT` and a sentence saying kernel objects are not customizable in this version, where it created `.custom`; `POST /api/v1/automation/:name/clone` of a flow a kernel package ships, with no `package` named, answers that `409` where it answered `200` with the copy in `.custom`; and the exported `pairOf`, `isPairOf` and `appOf` (`@objectstack/metadata-core`), `isServedOverrideRowOf` and the `installed` member of `OverrideWriteClosure` (`@objectstack/metadata-protocol`), and `resolveFlowPrecedence` / `describeFlowContender` (`@objectstack/service-automation`) take the installed-manifest lookup as a required argument. Widening: five new exports, `installedManifestsOf`, `InstalledManifestLookup`, `AppLookupManifest` and `InstalledPackageSource` (`@objectstack/metadata-core`) and `kernelPackageScope` (`@objectstack/metadata-protocol`), and one new `noticeCode` value on the permission-set clone answer, `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`, for a copy of a kernel-shipped set that now lands environment-wide. Metadata-protocol: none **BREAKING** for a caller that ensures the customization pair of a kernel package — a platform package of scope `system` (users, organizations, security, settings and the rest of the platform suite) or `cloud` — and for a TypeScript importer of the pair helpers named above. -- **Why (ruling records 6691022506 and 6691217245 on ADR-0146 D3).** The customization unit is the App, and the kernel is not an App a customer owns: it ships with the runtime at the runtime's version and is always installed whole. In this version no kernel object is customizable, so the door creates no pair for one. Which kernel objects a later version opens (a whitelist) is that version's decision. +- **Why (ruling records 6691022506 and 6691217245 on ADR-0146 D3).** The customization unit is the App, and the kernel is not an App a customer owns: it ships with the runtime, at the runtime's version (installing it whole is the goal ruling 6691022506 records, not yet what every `serve` tier does). In this version no kernel object is customizable, so the door creates no pair for one. Which kernel objects a later version opens (a whitelist) is that version's decision. - **What the door answers.** `409 RESOURCE_CONFLICT`, the status and code the door already answers when a base's state rules the pair out (a writable base, no namespace, a version that is not SemVer). Nothing is created or written. Every other base is answered exactly as before. - **A kernel pair that already exists stays.** An earlier version of the door may have created `.custom` on a deployment. It stays installed, the overrides inside it stay served by every read of the kernel item's name, and a further save into it is still admitted. Only the ensure call is refused. - **A copy of a platform permission set.** `POST /api/v1/security/permission-sets/:name/clone` with no `package` named landed the copy of a kernel-shipped set (`member_default` and the platform's other sets) in `.custom`. It now saves the copy environment-wide, as it already does for a set authored in the environment, with the notice code `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE` and a sentence naming the kernel package and `package`. A Console that does not know the new code shows that sentence as sent. Naming a writable package with `package` still lands the copy there. The flow clone door answers a kernel-shipped flow with the ensure door's sentence. -- **The closes that named the pair.** A package-less save, or a save naming the kernel package, onto a kernel item was refused naming the pair as the fix. That fix no longer exists, so those two closes leave the save open, as they already do for every other base the ensure door refuses. +- **The closes stay shut on a kernel item.** A package-less write, or a write naming the kernel package, onto an item a kernel package ships is still refused `403 NOT_OVERRIDABLE` exactly where it was refused before (ADR-0146 D11). The sentence no longer names the pair as the fix, since there is none to ensure: it says kernel objects are not customizable in this version. A later version's kernel whitelist is what opens such an item. - **The lookup.** Which pair customizes a package is asked through the installed manifests of the registry the caller serves (`installedManifestsOf(registry)`). Today the answer is unchanged for every package: `appOf` is still the identity. The argument is threaded through every site now so that resolving a module to its App is one change in `appOf`. - **`os validate`'s customization-namespace advisory** now skips a package that is itself a pair through `isCustomizationPackage`, the one spelling of that question. An id ending in `.custom` that no base derives (`a.custom.custom`, a bare `.custom`) is now judged like any other package. -- **Who moves, and the fix.** A script or client that ensured a kernel package's pair drops that call: the kernel item is served as it ships, and an existing pair keeps serving what it holds. A TypeScript importer of the helpers passes `installedManifestsOf(engine.registry)`, or `installedManifestsOf(undefined)` where it holds no registry. +- **Who moves, and the fix.** A script or client that ensured a kernel package's pair drops that call: the kernel item is served as it ships, and an existing pair keeps serving what it holds. A vendor package that declares `scope: 'system'` or `'cloud'` in its own manifest is read as kernel here, as `isWritablePackage` already reads it; its remedy is to drop the scope, which belongs to the platform's own packages. A TypeScript importer of the helpers passes `installedManifestsOf(engine.registry)`, or `installedManifestsOf(undefined)` where it holds no registry. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 44593fe05f7..17e11e909ae 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -182,7 +182,7 @@ document **whole** for every read that serves the base's name > **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and on a SQL database (SQLite, PostgreSQL, MySQL) still holding one a v18 `os serve` refuses to start until the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) has carried such a row to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item. -> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)). A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. So does the package-less write of a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so the ceremony lists the container and leaves it, and its views move into the pair one by one, each as a single view. +> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)); for an item a kernel package (scope `system` or `cloud`) ships, the same write is refused with a sentence that names no pair, because kernel objects are not customizable in this version and a kernel package has no pair. A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. So does the package-less write of a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so the ceremony lists the container and leaves it, and its views move into the pair one by one, each as a single view. | Type | `allowEnvironmentOverlay` | Rationale | | :--- | :---: | :--- | @@ -213,9 +213,9 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ | Type | Sanctioned path the refusal names | | :--- | :--- | -| `flow` | Declare an override from the base's paired `.custom`, or rename it (a write from any other package is told to rename: only the pair declares an override), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). The declared override is armed in the base flow's place, keeps its one activation switch, and a run suspended on the base finishes on the version it started on (ADR-0146 D9). A copy under a new name is "New similar", the clone door `POST /api/v1/automation/:name/clone` (body `{name, label}`), which the removal refusal still names. A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `.custom` (created on first use), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) D3, D4). | +| `flow` | Declare an override from the base's paired `.custom`, or rename it (a write from any other package is told to rename: only the pair declares an override), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). The declared override is armed in the base flow's place, keeps its one activation switch, and a run suspended on the base finishes on the version it started on (ADR-0146 D9). A copy under a new name is "New similar", the clone door `POST /api/v1/automation/:name/clone` (body `{name, label}`), which the removal refusal still names. A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `.custom` (created on first use; a kernel package, of scope `system` or `cloud`, has no pair in this version, so a copy of a flow it ships answers the ensure door's `409` until the body names `package`), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) D3, D4). | | `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. | -| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `.custom` (ADR-0146 D3/D6). | +| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `.custom`, and a copy of a set a kernel package ships lands environment-wide (ADR-0146 D3/D6). | A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable. diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts index be49aae3c8c..34444f6fcb5 100644 --- a/packages/metadata-protocol/src/protocol-customization-package.ts +++ b/packages/metadata-protocol/src/protocol-customization-package.ts @@ -154,7 +154,8 @@ interface PairPackageRow { * package — `system` or `cloud`, the scopes the platform delivers * ({@link READ_ONLY_PACKAGE_SCOPES}) — or `undefined` for any other package. * The kernel is one suite that ships with the runtime at the runtime's version - * and is always installed whole, so it is no customer's unit to customize: in + * (installing it whole is the goal ruling 6691022506 records), so it is no + * customer's unit to customize: in * this version every kernel object is served as not customizable, and the * ensure door creates no pair for a kernel package. Which kernel objects a * later version opens to customization (a whitelist) is that version's diff --git a/packages/runtime/src/domains/packages-ensure-customization.test.ts b/packages/runtime/src/domains/packages-ensure-customization.test.ts index ed57f83cb94..2159692f5a8 100644 --- a/packages/runtime/src/domains/packages-ensure-customization.test.ts +++ b/packages/runtime/src/domains/packages-ensure-customization.test.ts @@ -248,7 +248,7 @@ describe(`${ENSURE_PATH}: each refusal is worded and leaves nothing`, () => { }); // [ruling record 6691217245] The kernel — the packages of scope `system` - // and `cloud` — ships with the runtime and is installed whole: no kernel + // and `cloud` — ships with the runtime, at its version: no kernel // object is customizable in this version, so the door ensures no pair for // one. A narrowing: before it, this call created `.custom`. for (const scope of ['system', 'cloud'] as const) {