diff --git a/.changeset/28410-override-write-gate.md b/.changeset/28410-override-write-gate.md new file mode 100644 index 00000000000..9c95ba99fea --- /dev/null +++ b/.changeset/28410-override-write-gate.md @@ -0,0 +1,38 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/cli': minor +'@objectstack/cloud-connection': minor +'@objectstack/spec': patch +--- + +**BREAKING** feat(metadata-protocol)!: the override write gate — only a base's paired `.custom` package declares an override, and a dependent's item under a base item's name is refused (ADR-0146 D2, #28410) + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed +Skills-docs: none — no file under `skills/**` teaches the `overrides` declaration, a `.custom` package or the sealed-write sentence; the `content/docs/references/**` pages that show `overrides` are generated from the spec and do not move. + + + +A dependent package used to be able to ship, publish or install an item under the name an item of a package in its dependency closure already has, and nothing said which one a reader would get. Ruling A on #28390 settles it: only the base's paired `.custom` package declares an override (`overrides: { package: '', basedOn }`), and a dependent's same-named item has one fix, rename. + +**What is refused**, `403 NOT_OVERRIDABLE` (the code and status the sealed write already answers), each with its own sentence: + +- **An undeclared same name inside the closure.** A dependent's item bearing the name of an item a read-only (installed) package in its dependency closure ships, on a type a declared override can replace. In `.custom` the sentence says to declare it or rename it; in any other dependent, to rename it. +- **`overrides` outside the pair.** A declaration on a package that is not the base's paired `.custom`. +- **A declaration the closure cannot back.** `overrides.package` outside the declaring package's dependency closure (the sentence says to declare the dependency), or a base that ships no item of that type and name. +- **A type that takes no override.** An excluded type (permission, position, capability, the code-only types…), pointing to creating a new item under a new name; an `object`, pointing to `objectExtensions`. +- **A channel not yet open.** A declared `flow` ("flow customization is not yet available") or `action`. + +**Where.** The save door (`saveMetaItem`, so `PUT /api/v1/meta/:type/:name` and every authoring path through it), the promotion (`publishMetaItem` and `publishPackageDrafts`), `os validate` (each package of the artifact against its closure inside the artifact; exit 1), and the marketplace managed install (`POST /api/v1/marketplace/install-local`, before any write). The undeclared-same-name refusal is asked last at the publish doors, so every refusal those doors already answered keeps its code and status. + +**Admitted.** The base's paired `.custom` declaring an override of an item its base ships, on an open type: the save and the promotion write it as the pair's own row, and the managed install registers it. + +**The install answer lists upgrade collisions.** An install of a base whose new version ships a name an installed dependent already holds, undeclared, answers `200` with `overrideCollisions` (`type`, `name`, `packageId`, `rule`, `message`), one row per held item; the key is omitted when there is none. The install is not refused: the dependent's item is the one to rename, or in `.custom`, to declare. + +**The sealed sentence.** A write over a sealed item of a type a declared override can replace now names the writer's route in place of none. Written from the base's paired `.custom`, or naming no package, it ends "To change it, declare an override from '.custom' (overrides: { package: '', basedOn }), or rename the item."; written from any other package, "To change it, rename the item: only '.custom' may declare an override of ''." + +**A malformed `overrides` is not a declaration.** A value the spec's `OverrideDeclarationSchema` refuses (null, not an object, no or an empty `package`, no `basedOn`) is not judged by the gate; the save door's schema gate answers it (`422 INVALID_METADATA`), and `os validate` reports it at its schema step. + +`@objectstack/metadata-protocol` exports the gate's rule, `judgeOverrideWrite`, and its item walk, `overrideWriteItemsOf`, so the three doors ask one judgement. + +`@objectstack/spec` records the two new emitters of `NOT_OVERRIDABLE` in its error-code provenance ledger (`api/error-code-ledger.zod.ts`, under `@objectstack/cli` and `@objectstack/cloud-connection`). The code was already registered, so the union, casing and every schema are unchanged. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 9acd638b311..75bd3b08a0c 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -74,6 +74,9 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s // over the parsed stack the way the load path registers it. The verdict is // `@objectstack/objectql`'s; see the module header. import { findViewContainerNameRefusals } from '../utils/view-container-names.js'; +// [ADR-0146 D2, #28410] The override write gate's author-time half: the publish +// door's own judge, walked over the artifact's packages. +import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js'; // A field `picklist` that names no picklist the stack declares — refused, or // reported when the declaring package depends on packages outside the stack. // Walked the way the load path registers, like the refusal above. @@ -658,6 +661,35 @@ export default class Validate extends Command { } } + // 3a-iii. [ADR-0146 D2, #28410] The override write gate, at author time. + // Only the base's paired `.custom` declares an override; a + // dependent's item bearing the name a package in its closure ships is + // refused (rename), and an `overrides` declaration the gate does not + // admit is refused. The verdict is the publish door's own + // (`judgeOverrideWrite`), so a green validate is a publish the door + // accepts; see `utils/override-declarations.ts` for the walk. + const overrideRefusals = findOverrideDeclarationRefusals(result.data as Record); + if (overrideRefusals.length > 0) { + if (flags.json) { + await emitJson({ + valid: false, + errors: overrideRefusals, + warnings: warningsSoFar(), + conversions: conversionNotices, + duration: timer.elapsed(), + }); + this.exit(1); + } + const n = overrideRefusals.length; + console.log(''); + printError(`The publish door would refuse ${n} item${n > 1 ? 's' : ''} under the override write gate (ADR-0146 D2)`); + printBulletList( + overrideRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`), + { noun: 'override refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, + ); + this.exit(1); + } + // 3b. [#3366] Installable-provider preflight — the shift-left of the // `serve`-time capability check. `os validate` previously only checked // the `requires` tokens against the vocabulary (ADR-0066), never diff --git a/packages/cli/src/utils/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts new file mode 100644 index 00000000000..f40b9f49338 --- /dev/null +++ b/packages/cli/src/utils/override-declarations.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D2, #28410] `findOverrideDeclarationRefusals` — the WALK `os + * validate` runs the override write gate's judge over. + * + * The verdict is `@objectstack/metadata-protocol`'s `judgeOverrideWrite`, the + * function the publish doors ask; the protocol's own file pins its sentences. + * What is pinned here is which items the walk hands the judge, under which + * package and against which closure: every row's message is asserted EQUAL to + * the judge's answer for the same subject, never re-spelled. + */ + +import { describe, expect, it } from 'vitest'; +import { judgeOverrideWrite } from '@objectstack/metadata-protocol'; +import { findOverrideDeclarationRefusals } from './override-declarations.js'; + +const BASE = 'com.acme.crm'; +const PAIR = 'com.acme.crm.custom'; +const PARTNER = 'com.acme.partner'; + +const manifest = (id: string, dependencies: string[] = []) => ({ + id, name: id, version: '1.0.0', type: 'app', + ...(dependencies.length ? { dependencies: Object.fromEntries(dependencies.map((d) => [d, '*'])) } : {}), +}); +const pkg = (id: string, dependencies: string[], collections: Record) => + ({ manifest: { ...manifest(id, dependencies), ...collections } }); + +const base = pkg(BASE, [], { + dashboards: [{ name: 'crm_board', label: 'Board' }], + pages: [{ name: 'crm_home', label: 'Home' }], + actions: [{ name: 'log_call', objectName: 'crm_account', label: 'Log' }], +}); + +const judged = (type: string, name: string, packageId: string, item: unknown, dependencies: string[], shippers: string[]) => { + const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers }); + if (v.verdict !== 'refuse') throw new Error(`expected a refusal, got ${v.verdict}`); + return v.message; +}; + +describe('#28410 — findOverrideDeclarationRefusals walks the artifact under the publish door\'s judge', () => { + it('a partner\'s item bearing a name the base in its closure ships is refused, in the judge\'s words', () => { + const item = { name: 'crm_board', label: 'Mine' }; + const rows = findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [base, pkg(PARTNER, [BASE], { dashboards: [item] })], + }); + expect(rows).toEqual([{ + path: 'packages[1].manifest.dashboards[0]', + code: 'NOT_OVERRIDABLE', + rule: 'undeclared_same_name', + packageId: PARTNER, + message: judged('dashboard', 'crm_board', PARTNER, item, [BASE], [BASE]), + }]); + }); + + it('the closure is transitive through the artifact\'s packages', () => { + const rows = findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [base, pkg('com.acme.mid', [BASE], {}), pkg(PARTNER, ['com.acme.mid'], { pages: [{ name: 'crm_home' }] })], + }); + expect(rows.map((r) => [r.packageId, r.rule])).toEqual([[PARTNER, 'undeclared_same_name']]); + }); + + it('controls: a package outside the closure, an own name, and an action on another object are not refused', () => { + expect(findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [ + base, + pkg('com.other', [], { dashboards: [{ name: 'crm_board' }] }), + pkg(PARTNER, [BASE], { + dashboards: [{ name: 'partner_board' }], + actions: [{ name: 'log_call', objectName: 'partner_lead', label: 'Log' }], + }), + ], + })).toEqual([]); + }); + + it('the pair\'s declared override is admitted; its undeclared same name is told to declare or rename', () => { + const declared = { name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }; + const undeclared = { name: 'crm_home' }; + const rows = findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [base, pkg(PAIR, [BASE], { dashboards: [declared], pages: [undeclared] })], + }); + expect(rows).toEqual([{ + path: 'packages[1].manifest.pages[0]', + code: 'NOT_OVERRIDABLE', + rule: 'undeclared_same_name', + packageId: PAIR, + message: judged('page', 'crm_home', PAIR, undeclared, [BASE], [BASE]), + }]); + expect(rows[0].message).toMatch(/Declare it/); + }); + + it('a one-package .custom stack whose base is installed elsewhere: declarations are judged, the shipped name is left to the door', () => { + const rows = findOverrideDeclarationRefusals({ + manifest: manifest(PAIR, [BASE]), + dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }], + objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' } }], + flows: [{ name: 'crm_flow', overrides: { package: BASE, basedOn: '1.0.0' } }], + pages: [{ name: 'crm_home', overrides: { package: 'com.unlisted', basedOn: '1.0.0' } }], + }); + expect(rows.map((r) => [r.path, r.rule])).toEqual([ + ['objects[0]', 'declared_object'], + ['flows[0]', 'declared_type_not_yet_available'], + ['pages[0]', 'declared_outside_pair'], + ]); + }); + + it('a dependent carrying `overrides` is refused, and a declaration of an item the base does not ship is refused', () => { + const rows = findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [ + base, + pkg(PARTNER, [BASE], { dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }] }), + pkg(PAIR, [BASE], { dashboards: [{ name: 'no_such_board', overrides: { package: BASE, basedOn: '1.0.0' } }] }), + ], + }); + expect(rows.map((r) => [r.packageId, r.rule])).toEqual([ + [PARTNER, 'declared_outside_pair'], + [PAIR, 'declared_package_ships_no_such_item'], + ]); + }); + + it('a pair that does not depend on its base is told to declare the dependency', () => { + const rows = findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [base, pkg(PAIR, [], { dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }] })], + }); + expect(rows.map((r) => r.rule)).toEqual(['declared_package_outside_closure']); + expect(rows[0].message).toMatch(/Declare a dependency on 'com\.acme\.crm'/); + }); +}); diff --git a/packages/cli/src/utils/override-declarations.ts b/packages/cli/src/utils/override-declarations.ts new file mode 100644 index 00000000000..953aa2a68f4 --- /dev/null +++ b/packages/cli/src/utils/override-declarations.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os validate`'s half of the override write gate (ADR-0146 D2, ruling A on + * #28390; card #28410). + * + * ## One judge, not a second rule + * + * The verdict is `@objectstack/metadata-protocol`'s `judgeOverrideWrite`, the + * SAME function the publish doors (`saveMetaItem`, `publishMetaItem`, + * `publishPackageDrafts`) and the marketplace managed install ask. ⛔ Not an + * `@objectstack/lint` rule and not a re-spelling of it here: a twin agrees + * with the doors only until one of the two is edited. What this module owns + * is the WALK — which items an artifact carries, under which package, and + * which packages of the artifact's closure ship the same name — and nothing + * about the verdict. + * + * ## The walk + * + * Each package of the artifact (`packages[]`, ADR-0130 D4), or the stack + * itself when it carries none, is judged against its dependency closure: + * the manifest's `dependencies`, followed transitively through the packages + * this same artifact carries. + * + * - A same-named item is found only where both packages are in this + * artifact: a base the artifact does not carry is not visible here, and + * the publish door judges that write against the installed base. + * - A DECLARED item (`overrides: { package, basedOn }`) is judged whole — + * the pair predicate, the closure, the type — wherever its base lives. + * When the base is in the closure but not in this artifact, whether it + * ships the name is the one fact this door cannot read, so it is assumed + * and the publish door answers it. + * + * Items are named the way the registry names them (`overrideWriteItemsOf`, + * the walk the marketplace install door asks too): an `action` by + * `:`, a `views:` container by the views it expands into. + */ + +import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem } from '@objectstack/metadata-protocol'; +import { OverrideDeclarationSchema } from '@objectstack/spec/kernel'; + +import { artifactPackages } from './artifact-packages.js'; + +/** One refusal, located. `message` is the publish door's, verbatim. */ +export interface OverrideDeclarationRefusalRow { + /** Where the item sits in the parsed stack, e.g. `packages[1].manifest.views[0]`. */ + path: string; + /** The code the publish door refuses the same write with. */ + code: 'NOT_OVERRIDABLE'; + /** Which of the gate's refusals this is (`judgeOverrideWrite`'s `rule`). */ + rule: string; + /** The package the item is written into. */ + packageId: string; + message: string; +} + +interface WalkedPackage { + id: string; + dependencies: string[]; + items: OverrideWriteItem[]; +} + +const isRecord = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +const dependencyIds = (manifest: Record | undefined): string[] => { + const declared = manifest?.dependencies; + if (Array.isArray(declared)) return declared.filter((d): d is string => typeof d === 'string'); + return isRecord(declared) ? Object.keys(declared) : []; +}; + +/** The artifact's packages: `packages[]`, or the stack itself as one package. */ +function packagesOf(parsed: Record): WalkedPackage[] { + const entries = artifactPackages(parsed); + if (entries.length > 0) { + return entries.map(({ index, id, body }) => ({ + id, + dependencies: dependencyIds(body), + items: overrideWriteItemsOf(body, `packages[${index}].manifest.`), + })); + } + 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, '') }]; +} + +/** `id`'s dependency closure, followed through the packages this artifact carries. */ +function closureOf(id: string, byId: ReadonlyMap): string[] { + const seen = new Set(); + const queue = [...(byId.get(id)?.dependencies ?? [])]; + while (queue.length > 0) { + const next = queue.shift()!; + if (next === id || seen.has(next)) continue; + seen.add(next); + queue.push(...(byId.get(next)?.dependencies ?? [])); + } + return [...seen]; +} + +/** + * Every item of the parsed stack the publish door would refuse under the + * override write gate — an undeclared same name inside the closure, or an + * `overrides` declaration the gate does not admit. + */ +export function findOverrideDeclarationRefusals(parsed: Record): OverrideDeclarationRefusalRow[] { + 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}`))])); + const rows: OverrideDeclarationRefusalRow[] = []; + for (const pkg of packages) { + const dependencies = closureOf(pkg.id, byId); + for (const walked of pkg.items) { + const key = `${walked.type}\u0000${walked.identity}`; + const shippers = dependencies.filter((d) => shipped.get(d)?.has(key)); + // A malformed `overrides` is no declaration (the schema step reports + // it), so it names no base here either, as the gate reads it. + const declared = isRecord(walked.item) ? OverrideDeclarationSchema.safeParse(walked.item.overrides) : undefined; + const base = declared?.success ? declared.data.package : undefined; + // A declared base the closure names but this artifact does not carry: + // whether it ships the name is read at the publish door. + if (base !== undefined && dependencies.includes(base) && !byId.has(base) && !shippers.includes(base)) { + shippers.push(base); + } + const verdict = judgeOverrideWrite( + { type: walked.type, name: walked.name, packageId: pkg.id, item: walked.item }, + { dependencies, shippers }, + ); + if (verdict.verdict !== 'refuse') continue; + rows.push({ path: walked.path, code: 'NOT_OVERRIDABLE', rule: verdict.rule, packageId: pkg.id, message: verdict.message }); + } + } + return rows; +} diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index c4808ea6452..076046367ff 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -226,7 +226,17 @@ const BUILD_ONLY_GATES: Readonly> = { * ledger stays, empty, because it is the only honest place the closed roster * has for the next validate-only gate. */ -const VALIDATE_ONLY_GATES: Readonly> = {}; +const VALIDATE_ONLY_GATES: Readonly> = { + // [#28410] A real gap in the build's direction, reported and not closed: + // card #28410 (ADR-0146 D2, ruling A on #28390) named three gate points for + // the override write gate — the publish doors, `os validate` and the + // install door — and `os build` is not one of them. The artifact a build + // emits is still judged where it lands: the marketplace managed install + // and the publish doors refuse the same items with the same judge. Whether + // the build refuses too is a separate decision, raised on the card. + findOverrideDeclarationRefusals: + 'card #28410 placed the override write gate at publish, os validate and install; os build is a fourth point it did not name (open question on #28410). The install and publish doors refuse the same items with the same judge.', +}; /** * Everything else the two commands call, and the reason each one is NOT an diff --git a/packages/cli/test/validate-override-declarations.test.ts b/packages/cli/test/validate-override-declarations.test.ts new file mode 100644 index 00000000000..7d75aa71f26 --- /dev/null +++ b/packages/cli/test/validate-override-declarations.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D2, #28410] `os validate` asks the override write gate — the + * publish door's own judge (`judgeOverrideWrite`), over the walk + * `utils/override-declarations.ts` takes, whose own file pins which items it + * judges. What is pinned here is the command: a stack whose `overrides` + * declaration the gate does not admit exits 1, the refusal on `errors` with + * the door's code; the base's paired `.custom` declaring the same override + * validates clean. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +const stack = (id: string) => ` +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { + id: '${id}', name: 'ovr', version: '1.0.0', type: 'app', namespace: 'ovr', + dependencies: { 'com.acme.crm': '^1.0.0' }, + }, + dashboards: [ + { name: 'crm_board', label: 'Board', widgets: [], overrides: { package: 'com.acme.crm', basedOn: '1.0.0' } }, + ], +}, { strict: false }); +`; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runValidate(cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, 'validate', '--json'], + // `childEnv()`, never a bulk `process.env` copy (check:cli-test-child-env). + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +let partnerDir: string; +let pairDir: string; + +beforeAll(() => { + partnerDir = mkdtempSync(join(tmpdir(), 'os-validate-override-partner-')); + writeFileSync(join(partnerDir, 'objectstack.config.ts'), stack('com.acme.partner')); + linkSpec(partnerDir); + pairDir = mkdtempSync(join(tmpdir(), 'os-validate-override-pair-')); + writeFileSync(join(pairDir, 'objectstack.config.ts'), stack('com.acme.crm.custom')); + linkSpec(pairDir); +}); + +afterAll(() => { + rmSync(partnerDir, { recursive: true, force: true }); + rmSync(pairDir, { recursive: true, force: true }); +}); + +describe('#28410 — `os validate` refuses an override declaration the publish door refuses', () => { + it('a dependent carrying `overrides` exits 1, with the gate\'s refusal on `errors`', async () => { + const run = await runValidate(partnerDir); + expect(run.code, run.stdout + run.stderr).toBe(1); + const payload = JSON.parse(run.stdout) as { valid: boolean; errors: Array> }; + expect(payload.valid).toBe(false); + expect(payload.errors.map((e) => [e.path, e.code, e.rule, e.packageId])).toEqual([ + ['dashboards[0]', 'NOT_OVERRIDABLE', 'declared_outside_pair', 'com.acme.partner'], + ]); + expect(String(payload.errors[0].message)).toContain("only 'com.acme.crm.custom' may declare an override of 'com.acme.crm'"); + }, 120_000); + + it('CONTROL — the base\'s paired .custom declaring the same override validates clean', async () => { + const run = await runValidate(pairDir); + expect(run.code, run.stdout + run.stderr).toBe(0); + const payload = JSON.parse(run.stdout) as { valid: boolean; errors?: unknown[] }; + expect(payload.valid).toBe(true); + }, 120_000); +}); 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 new file mode 100644 index 00000000000..8be40f717c5 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D2, ruling A on #28390; #28410] Step 2d of `POST + * /api/v1/marketplace/install-local` — the override write gate at the managed + * install, the door a package's items enter through as code, past every + * publish door. + * + * The verdict is `@objectstack/metadata-protocol`'s `judgeOverrideWrite`, the + * publish doors' own; its sentences are pinned in that package. What is + * pinned here is the door: + * + * - an incoming item the gate refuses stops the install `403 + * NOT_OVERRIDABLE`, one entry per item in `details.refusals`, before any + * write — nothing registered, no ledger entry; + * - the base's paired `.custom` installs its admitted declarations; + * - an install of a base lists, on its answer (`overrideCollisions`), the + * names installed dependents already hold, undeclared, that it now ships — + * and omits the key when there are none. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The install binds the package's handlers through `@objectstack/runtime` (a +// lazy `import()` inside the plugin); its first load is paid here, at module +// top — never inside a clocked `it` (`scripts/check-test-source-alias.mjs`). +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'; + +type Handler = (c: any) => Promise; + +const ROUTE = 'POST /api/v1/marketplace/install-local'; +const BASE = 'com.acme.crm'; +const PAIR = 'com.acme.crm.custom'; +const PARTNER = 'com.acme.partner'; + +const deps = (...ids: string[]) => Object.fromEntries(ids.map((id) => [id, '*'])); + +/** What each installed package holds, as the protocol's per-package listing answers it. */ +type Holdings = Record>>>; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +/** + * A kernel whose registry holds `installed` (BASE read-only — a code or + * marketplace package is in `manifests`) and whose protocol lists `holdings`. + */ +function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record }>, holdings: Holdings) { + const hooks = new Map(); + const registered: any[] = []; + const records = installed.map((p) => ({ manifest: { id: p.id, version: '1.0.0', ...(p.dependencies ? { dependencies: p.dependencies } : {}) } })); + const services: Record = { + manifest: { register: (m: any) => registered.push({ manifest: m }) }, + auth: installerAuthService(), + objectql: withInstallerGrants({ + syncSchemas: async () => undefined, + manifests: new Map(installed.some((p) => p.id === BASE) ? [[BASE, {}]] : []), + registry: { + getAllPackages: () => [...records, ...registered], + getPackage: (id: string) => records.find((r) => r.manifest.id === id), + }, + }), + protocol: { + getMetaItems: async ({ type, packageId }: { type: string; packageId?: string }) => ({ + type, + items: (holdings[packageId ?? '']?.[type] ?? []).map((i) => ({ ...i, _packageId: packageId })), + }), + }, + metadata: { get: async () => undefined }, + }; + return { + registered, + ctx: { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }, + fire: async () => { await hooks.get('kernel:ready')?.(); }, + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { req: { url: 'http://localhost:3000/x', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-override-gate-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +const BASE_HOLDINGS: Holdings = { + [BASE]: { + dashboard: [{ name: 'crm_board', label: 'Board' }], + object: [{ name: 'crm_account' }], + }, +}; + +async function install( + manifest: Record, + installed: Array<{ id: string; dependencies?: Record }> = [{ id: BASE }], + holdings: Holdings = BASE_HOLDINGS, +) { + const rawApp = makeRawApp(); + const k = makeCtx(rawApp, installed, holdings); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(k.ctx as any); + await k.fire(); + const res = await rawApp.routes.get(ROUTE)!(makeC({ manifest })); + const registered = k.registered.map((r) => r.manifest.id).filter((id) => id === manifest.id); + return { res, registered, ledger: readdirSync(dir) }; +} + +const refusedOf = (res: any) => (res.payload?.error?.details?.refusals ?? []).map((r: any) => [r.type, r.name, r.rule]); + +describe('install-local 2d — the override write gate at the managed install (ADR-0146 D2)', () => { + it('a dependent shipping an item under the name its base ships is refused 403, and nothing is written', async () => { + const item = { name: 'crm_board', label: 'Mine' }; + const { res, registered, ledger } = await install({ + id: PARTNER, version: '1.0.0', dependencies: deps(BASE), dashboards: [item], + }); + expect(res.status, JSON.stringify(res.payload)).toBe(403); + expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE'); + const verdict = judgeOverrideWrite( + { type: 'dashboard', name: 'crm_board', packageId: PARTNER, item }, + { dependencies: [BASE], shippers: [BASE] }, + ); + expect(res.payload.error.details.refusals).toEqual([ + { type: 'dashboard', name: 'crm_board', rule: 'undeclared_same_name', message: (verdict as any).message }, + ]); + expect(res.payload.error.message).toContain((verdict as any).message); + expect(registered).toEqual([]); + expect(ledger).toEqual([]); + }); + + it('a dependent carrying `overrides` is refused: only the base\'s paired .custom declares', async () => { + const { res, registered } = await install({ + id: PARTNER, version: '1.0.0', dependencies: deps(BASE), + dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }], + }); + expect(res.status).toBe(403); + expect(refusedOf(res)).toEqual([['dashboard', 'crm_board', 'declared_outside_pair']]); + expect(registered).toEqual([]); + }); + + it('the paired .custom declaring an object override is refused (objects are extended)', async () => { + const { res } = await install({ + id: PAIR, version: '1.0.0', dependencies: deps(BASE), + objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' }, fields: {} }], + }); + expect(res.status).toBe(403); + expect(refusedOf(res)).toEqual([['object', 'crm_account', 'declared_object']]); + }); + + it('the paired .custom installs its declared override of an item the base ships', async () => { + const { res, registered } = await install({ + id: PAIR, version: '1.0.0', dependencies: deps(BASE), + dashboards: [{ name: 'crm_board', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }], + }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(registered).toEqual([PAIR]); + expect(res.payload.data).not.toHaveProperty('overrideCollisions'); + }); + + it('CONTROL: a dependent shipping its own names installs, and its answer carries no collision key', async () => { + const { res, registered } = await install({ + id: PARTNER, version: '1.0.0', dependencies: deps(BASE), dashboards: [{ name: 'partner_board' }], + }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(registered).toEqual([PARTNER]); + expect(res.payload.data).not.toHaveProperty('overrideCollisions'); + }); + + it('an install of a base lists the names installed dependents already hold undeclared, and never a declared override', async () => { + const partnerItem = { name: 'crm_new', label: 'Partner new' }; + const { res, registered } = await install( + { + id: BASE, version: '2.0.0', + dashboards: [{ name: 'crm_board' }, { name: 'crm_new' }], + pages: [{ name: 'crm_home' }], + }, + [{ id: PARTNER, dependencies: deps(BASE) }, { id: PAIR, dependencies: deps(BASE) }], + { + [PARTNER]: { dashboard: [partnerItem] }, + [PAIR]: { + dashboard: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }], + page: [{ name: 'crm_home' }], + }, + }, + ); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(registered).toEqual([BASE]); + const collisions = res.payload.data.overrideCollisions; + expect(collisions.map((c: any) => [c.packageId, c.type, c.name, c.rule])).toEqual([ + [PARTNER, 'dashboard', 'crm_new', 'undeclared_same_name'], + [PAIR, 'page', 'crm_home', 'undeclared_same_name'], + ]); + expect(collisions[1].message).toMatch(/Declare it/); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 45d260fb57a..a31ad81e098 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -238,6 +238,9 @@ import type { // before any write), the read-only-package rule (a namespace held by a WRITABLE base is // this environment's own metadata), and the client-facing sentence rule. import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol'; +// [ADR-0146 D2, #28410] The override write gate's one rule and its item walk, +// asked at the managed install exactly as the publish doors ask them. +import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteRule } from '@objectstack/metadata-protocol'; import { resolveThrownHttpError } from '@objectstack/types'; // [#25366] The code the registry's security-catalog refusal carries, compared // rather than `instanceof`-tested: the engine keeps the class unexported so a @@ -516,6 +519,71 @@ type NamespaceHoldingEngine = PackageWritabilityEngine & { }; }; +/** + * [ADR-0146 D2, #28410] What the managed install's override gate reads: the + * registry's package records (each package's declared `dependencies`) and the + * protocol's per-package item listing (`getMetaItems`, code-registered and + * authored items alike — a `.custom` an administrator built in Studio + * holds its items in the store, not the registry). + */ +type OverrideGateEngine = PackageWritabilityEngine & { + registry?: PackageWritabilityEngine['registry'] & { + getAllPackages?(): unknown; + }; +}; +type OverrideGateLister = { + getMetaItems?(request: { type: string; packageId?: string }): Promise | unknown; +}; + +/** One item the managed install refuses under the override write gate. */ +export interface OverrideInstallRefusal { + type: string; + name: string; + /** The delivered package the item belongs to — on a multi-package artifact only. */ + packageId?: string; + rule: OverrideWriteRule; + message: string; +} + +/** + * One name an installed dependent already holds that this (re)install of its + * base now ships, undeclared — listed on the answer, never refused: a base + * upgrade is the vendor's, and the dependent's item is the one to rename (or, + * in the base's paired `.custom`, to declare). + */ +export interface OverrideUpgradeCollision { + type: string; + name: string; + /** The installed package holding the name. */ + packageId: string; + rule: OverrideWriteRule; + message: string; +} + +function dependencyIdsOf(manifest: unknown): string[] { + const declared = (manifest as { dependencies?: unknown } | null | undefined)?.dependencies; + if (Array.isArray(declared)) return declared.filter((d): d is string => typeof d === 'string' && d !== ''); + return declared !== null && typeof declared === 'object' ? Object.keys(declared) : []; +} + +/** A listing answer as an item array: `getMetaItems` answers `{ items }`, a double may answer the array. */ +function listedItems(answer: unknown): Record[] { + const items = Array.isArray(answer) ? answer : (answer as { items?: unknown } | null | undefined)?.items; + return Array.isArray(items) + ? items.filter((i): i is Record => i !== null && typeof i === 'object' && !Array.isArray(i)) + : []; +} + +/** An item's registry identity — `overrideWriteItemsOf`'s: an action is `:`. */ +function listedIdentity(type: string, item: Record): string | undefined { + const name = typeof item.name === 'string' && item.name !== '' ? item.name : undefined; + if (name === undefined) return undefined; + if (type === 'action' && typeof item.objectName === 'string' && item.objectName !== '' && !name.includes(':')) { + return `${item.objectName}:${name}`; + } + return name; +} + /** A `{ … }` object — not an array, not null. */ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); @@ -1935,6 +2003,33 @@ export class MarketplaceInstallLocalPlugin implements Plugin { accessUpgradeConfirmation = access.confirmation; } + // 2d. [ADR-0146 D2, #28410] ⭐ THE OVERRIDE WRITE GATE. A managed + // install registers the package's items as code, past every publish + // door, so the gate those doors ask is asked here, on the converted + // body, before any write: only the base's paired `.custom` + // declares an override, a dependent's item bearing a name its + // closure ships is refused (rename), and a declaration the gate does + // not admit is refused — `403 NOT_OVERRIDABLE`, the publish doors' + // code and status, one sentence per item in `details`. On a base + // (re)install, the names installed dependents already hold that + // this version now ships are listed on the answer + // (`overrideCollisions`), never refused. + const overrideGate = await this.evaluateOverrideWriteGate(ctx, manifestId, converted); + if (overrideGate.refusals.length > 0) { + const n = overrideGate.refusals.length; + return c.json({ + success: false, + error: { + code: 'NOT_OVERRIDABLE', + message: `Package '${manifestId}' cannot be installed: ${n} of its item${n > 1 ? 's are' : ' is'} refused by ` + + `the override write gate (ADR-0146 D2). ${overrideGate.refusals[0].message}` + + (n > 1 ? ` (${n - 1} more in \`details.refusals\`.)` : '') + + ' Nothing was installed or written.', + details: { refusals: overrideGate.refusals }, + }, + }, 403); + } + // 3. Hot-register FIRST so a malformed inline manifest fails the // install loudly rather than persisting a broken record that // would also fail on every subsequent rehydrate. @@ -2113,6 +2208,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ...(datasources !== undefined ? { datasources } : {}), ...(unsetRefs.length > 0 ? { credentialNote: installedCredentialNote(unsetRefs, { door }) } : {}), upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null, + // [ADR-0146 D2, #28410] Names installed dependents already held, + // undeclared, that this version now ships (step 2d). Omitted when + // there are none, so that answer's key set is unchanged. + ...(overrideGate.collisions.length > 0 ? { overrideCollisions: overrideGate.collisions } : {}), translationsLoaded: seededSummary.translationsLoaded, seeded: seededSummary.seeded, // #6721: the RESOLVED ledger directory on THIS host — the same @@ -3071,6 +3170,148 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return null; }; + /** + * [ADR-0146 D2, ruling A on #28390; #28410] THE OVERRIDE WRITE GATE at the + * managed install — the door a package's items enter through as code + * (`manifest.register`), so the publish doors never see them. The rule is + * the publish doors' own (`judgeOverrideWrite`), over the walk `os + * validate` takes (`overrideWriteItemsOf`): + * + * - `refusals` — the incoming package's items the gate refuses: an + * undeclared same name a read-only package in its dependency closure + * ships, or an `overrides` declaration the gate does not admit (a + * dependent declaring, a base outside the closure, an excluded type, + * an object, a flow or action while their channel waits). A `.custom` + * package's admitted declarations install as at publish. + * - `collisions` — on a (re)install of a base, the names an installed + * dependent (a package whose closure holds this one) already holds, + * undeclared, that this version now ships. Listed on the answer, never + * refused (see {@link OverrideUpgradeCollision}). + * + * The card placed this gate point at `installPackage`; that door takes a + * manifest and no items (`PackageInstallManifestSchema`), and the + * unmanaged install copies through the save door (already gated), so the + * managed install is where a package's items arrive unjudged. + * + * Reads nothing it cannot: with no `objectql` registry or no protocol + * listing, the gate has no closure to judge and answers empty. + */ + private evaluateOverrideWriteGate = async ( + ctx: PluginContext, + manifestId: string, + converted: Record, + ): Promise<{ refusals: OverrideInstallRefusal[]; collisions: OverrideUpgradeCollision[] }> => { + const none = { refusals: [], collisions: [] }; + let ql: OverrideGateEngine | undefined; + let lister: OverrideGateLister | undefined; + try { ql = ctx.getService('objectql'); } catch { return none; } + try { lister = ctx.getService('protocol'); } catch { return none; } + const registry = ql?.registry; + if (!registry || typeof lister?.getMetaItems !== 'function') return none; + const getMetaItems = lister.getMetaItems.bind(lister); + // The packages this install delivers — each `packages[]` entry of a + // multi-package artifact (ADR-0130 D4), or the body itself — judged + // 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 entries: Record[] = Array.isArray(converted.packages) + ? converted.packages + .map((entry: unknown) => (isPlainObject(entry) && isPlainObject(entry.manifest) ? entry.manifest : undefined)) + .filter((m: unknown): m is Record => m !== undefined) + : [flattenPackageBody(converted)]; + 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 dependenciesOf = (id: string): string[] => delivered.get(id)?.dependencies + ?? dependencyIdsOf((registry.getPackage?.(id) as { manifest?: unknown } | undefined)?.manifest); + const closureOf = (id: string): string[] => { + const seen = new Set(); + const queue = [...dependenciesOf(id)]; + while (queue.length > 0) { + const next = queue.shift()!; + if (next === id || seen.has(next)) continue; + seen.add(next); + queue.push(...dependenciesOf(next)); + } + return [...seen]; + }; + // One listing per (type, package), read once; a delivered package's + // own items are read off its body. + const held = new Map>>(); + const heldBy = (type: string, packageId: string): Promise> => { + const key = `${type}\u0000${packageId}`; + let read = held.get(key); + if (!read) { + const local = delivered.get(packageId); + read = local + ? Promise.resolve(new Map(local.items.filter((i) => i.type === type).map((i) => [i.identity, i.item]))) + : (async () => { + const byIdentity = new Map(); + let answer: unknown; + try { answer = await getMetaItems({ type, packageId }); } catch { return byIdentity; } + for (const item of listedItems(answer)) { + const owner = item._packageId; + if (typeof owner === 'string' && owner !== packageId) continue; + const identity = listedIdentity(type, item); + if (identity !== undefined) byIdentity.set(identity, item); + } + return byIdentity; + })(); + held.set(key, read); + } + return read; + }; + const isBase = (id: string): boolean => delivered.has(id) || !isWritablePackage(ql, id); + + const refusals: OverrideInstallRefusal[] = []; + for (const [packageId, pkg] of delivered) { + const closure = closureOf(packageId); + const bases = closure.filter(isBase); + for (const item of pkg.items) { + const shippers: string[] = []; + for (const base of bases) if ((await heldBy(item.type, base)).has(item.identity)) shippers.push(base); + const verdict = judgeOverrideWrite( + { type: item.type, name: item.name, packageId, item: item.item }, + { dependencies: closure, shippers }, + ); + if (verdict.verdict === 'refuse') { + refusals.push({ + type: item.type, name: item.name, + ...(delivered.size > 1 ? { packageId } : {}), + rule: verdict.rule, message: verdict.message, + }); + } + } + } + + const collisions: OverrideUpgradeCollision[] = []; + const installed = registry.getAllPackages?.(); + const installedIds = (Array.isArray(installed) ? installed : []) + .map((p) => manifestIdOf(p)) + .filter((id): id is string => typeof id === 'string' && !delivered.has(id)); + for (const [baseId, base] of delivered) { + for (const dependent of installedIds) { + const dependentClosure = closureOf(dependent); + if (!dependentClosure.includes(baseId)) continue; + for (const item of base.items) { + const holding = (await heldBy(item.type, dependent)).get(item.identity); + if (holding === undefined) continue; + const verdict = judgeOverrideWrite( + { type: item.type, name: item.name, packageId: dependent, item: holding }, + { dependencies: dependentClosure, shippers: [baseId] }, + ); + if (verdict.verdict === 'refuse') { + collisions.push({ type: item.type, name: item.name, packageId: dependent, rule: verdict.rule, message: verdict.message }); + } + } + } + } + return { refusals, collisions }; + }; + /** * [ADR-0131 D6] The WRITABLE package holding this package's namespace, if * any — step 1f of {@link handleInstall}, the managed-after-unmanaged diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 831bb16463e..7b7680e54a5 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -31,6 +31,10 @@ export { SDUI_MANIFEST_SERVICE } from './runtime-authoring-gate.js'; // "package author's own bootstrap channel", replacing the `environmentId === // undefined` proxy the #4463 gate used to key its activation off. export type { MetadataAuthoringChannel } from './protocol.js'; +// [ADR-0146 D2, #28410] The override write gate's one rule, exported for the +// two gate points outside this package — `os validate` and the marketplace +// managed install — so all three doors ask one judgement and cannot drift. +export { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteSubject, type OverrideWriteClosure, type OverrideWriteRule, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; // The probe-first replacement's shared vocabulary: the raw-SQL seam type the // exported migrations below take, and the dialect classifier their reports diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 4d649c5c862..27271b43e2f 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -38,6 +38,15 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; +import { + customizationBasePackageId, + customizationPackageId, + getOverrideTypeClassification, + isCustomizationPairOf, + OverrideDeclarationSchema, +} from '@objectstack/spec/kernel'; +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -50,11 +59,14 @@ export interface PackagedBaseRefusalHost { isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; + packagedArtifactOwner: ObjectStackProtocolImplementation['packagedArtifactOwner']; + resolveWritePackageScope: ObjectStackProtocolImplementation['resolveWritePackageScope']; + shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf']; } /** 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)) { @@ -124,13 +136,340 @@ export function refusePackagedBaseOverride( // and names the managed package for every other type; the code and // the status are unchanged. [#23577] Every write reads it, whatever // the write meant to do: the door cannot tell a create from an edit. - const err = new Error(managedItemSealedSentence(request.type, request.name, 'save')); + // + // [ADR-0146 D2] On a type a declared override can replace, the + // sentence names the fixes the writer has — the pair (or a write + // naming no package) may declare the override from the base's paired + // `.custom` or rename; any other package may only rename — in place + // of the bare seal: the override write gate ({@link judgeOverrideWrite}) + // admits exactly the pair's declaration, so the seal is no longer the + // whole story. + const err = new Error( + managedItemSealedSentence(request.type, request.name, 'save') + + sealedDeclareOrRenameTail(request.type, this.packagedArtifactOwner(request), request.packageId), + ); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; throw err; } } +/** + * [ADR-0146 D2] The sealed write's second sentence on a type whose override + * channel is open, chosen by who writes: + * - the base's paired `.custom`, or a write that names no package (the + * administrator's package-less write, which may still move to the pair): + * declare the override from the pair, or rename; + * - any other named package: rename. Only the pair declares an override + * (ruling A on #28390), so a dependent's one fix is a new name, and the + * declare advice would send it to a refusal (`declared_outside_pair`). + * + * Empty for a type with no open channel — `flow` and `action` wait for + * ADR-0146 D9's conditions (their regime rows keep their own routes), and an + * excluded type has none — and when the shipping package has no pair id. + */ +function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerPackageId: unknown): string { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return ''; + const pair = customizationPackageId(basePackageId); + if (!pair.ok) return ''; + const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined; + if (writer !== undefined && writer !== pair.value) { + return ` To change it, rename the item: only '${pair.value}' may declare an override of '${basePackageId}'.`; + } + return ` To change it, declare an override from '${pair.value}' ` + + `(overrides: { package: '${basePackageId}', basedOn }), or rename the item.`; +} + +// ───────────────────────────────────────────────────────────────────────── +// ADR-0146 D2 — the override write gate (ruling A on decision card #28390) +// ───────────────────────────────────────────────────────────────────────── + +/** + * Whether a declared override of `type` is admitted today: the closed list's + * disposition is `overridable` AND the type waits for no ADR-0146 D9 + * condition. `flow` (D9 conditions, opened by its own card in this same gate) + * and `action` (its ledger switch first; refused for the whole epic) carry + * `conditions`, so they are classified overridable and still refused here. + * Read from `OVERRIDE_TYPE_CLASSIFICATION`, never re-derived. + */ +function isOpenOverrideType(singular: string): boolean { + const row = getOverrideTypeClassification(singular); + return row?.disposition === 'overridable' && (row.conditions?.length ?? 0) === 0; +} + +/** One item written into one package — what the override write gate judges. */ +export interface OverrideWriteSubject { + /** The metadata type, either spelling (folded here). */ + readonly type: string; + readonly name: string; + /** The package the item is written into; absent for a package-less row. */ + readonly packageId?: string | null; + /** The item body; its `overrides` key is the declaration. */ + readonly item?: unknown; +} + +/** What the gate knows about the written package's dependency closure. */ +export interface OverrideWriteClosure { + /** + * The packages the written package's `manifest.dependencies` reach, + * transitively, itself excluded. `undefined` when the closure cannot be + * read: an undeclared same name then has no base to collide with (the + * write is not narrowed on a guess), and a declaration is NOT admitted — + * admitting needs the base to be proven inside the closure. + */ + readonly dependencies: readonly string[] | undefined; + /** The packages of {@link dependencies} that ship an item of this type and name. */ + readonly shippers: readonly string[]; +} + +/** Which named refusal of ADR-0146 D2 / D6 / D9 / D10 a write meets. */ +export type OverrideWriteRule = + | 'undeclared_same_name' + | 'declared_outside_pair' + | 'declared_package_outside_closure' + | 'declared_package_ships_no_such_item' + | 'declared_type_not_overridable' + | 'declared_object' + | 'declared_type_not_yet_available'; + +/** + * The gate's verdict on one write: + * - `admit` — the base's paired `.custom` declares an override of an item + * `base` ships: the item is the pair's own row, and it does not edit the + * base's managed item, so the sealed-write refusal does not apply to it; + * - `refuse` — one named refusal, with its sentence; + * - `not_concerned` — no declaration and no base item of that name in the + * closure: every other gate answers as before. + */ +export type OverrideWriteVerdict = + | { readonly verdict: 'admit'; readonly base: string } + | { readonly verdict: 'refuse'; readonly rule: OverrideWriteRule; readonly message: string } + | { readonly verdict: 'not_concerned' }; + +const NOT_CONCERNED: OverrideWriteVerdict = { verdict: 'not_concerned' }; + +/** + * The declaration an item body carries: its `overrides` value when that value + * is one the spec's {@link OverrideDeclarationSchema} accepts, else none. + * + * A malformed `overrides` (null, not an object, no `package`, a `.custom` + * package named as the base, a missing `basedOn`, an unknown key) is NOT a + * declaration here: the gate is not concerned with it, and the door's schema + * gate answers it with its own `422 INVALID_METADATA` naming the key, which is + * the fact. The gate runs before that schema gate, so judging the malformed + * value as a declaration would answer `403 NOT_OVERRIDABLE` in its place. + */ +function declarationOf(item: unknown): { present: boolean; base: string | undefined } { + if (item === null || typeof item !== 'object' || !('overrides' in item)) return { present: false, base: undefined }; + const declared = OverrideDeclarationSchema.safeParse((item as { overrides?: unknown }).overrides); + if (!declared.success) return { present: false, base: undefined }; + return { present: true, base: declared.data.package }; +} + +/** + * THE override write gate's rule, as a pure verdict — the one judgement the + * publish doors (`saveMetaItem`, `publishMetaItem`, `publishPackageDrafts`), + * `os validate` and the marketplace install door all ask, so the three gate + * points cannot drift (ADR-0146 D2, ruling A on #28390: only the paired + * `.custom` declares an override; a dependent's same-named item has one + * fix, rename; a dependent carrying `overrides` is refused). + * + * The type list and the pair predicate are N-08's (`@objectstack/spec/kernel`: + * `OVERRIDE_TYPE_CLASSIFICATION`, `isCustomizationPairOf`), read, never + * re-derived. `basedOn` is recorded and never judged. + * + * Order: a declaration is judged on its type first (D10 `object`, D6 and the + * other excluded types, then D9's `flow` and `action`), then on who declares + * (the pair alone), then on what it names (inside the closure, and shipping + * that item). An undeclared item is judged only when a package of its closure + * ships the same name, on a type a declared override applies to; outside the + * closure two packages keep ADR-0048 §3.3/§3.4's coexistence on a bare name. + */ +export function judgeOverrideWrite( + subject: OverrideWriteSubject, + closure: OverrideWriteClosure, +): OverrideWriteVerdict { + const type = PLURAL_TO_SINGULAR[subject.type] ?? subject.type; + const { name } = subject; + const packageId = typeof subject.packageId === 'string' && subject.packageId.length > 0 + ? subject.packageId + : undefined; + const item = `'${type}/${name}'`; + const declaration = declarationOf(subject.item); + const refuse = (rule: OverrideWriteRule, message: string): OverrideWriteVerdict => + ({ verdict: 'refuse', rule, message }); + + if (declaration.present) { + const row = getOverrideTypeClassification(type); + if (row?.disposition === 'extend_only') { + return refuse('declared_object', + `${item} carries \`overrides\`, but an object is extended, never replaced (ADR-0146 D10). ` + + 'Remove `overrides` and add the change as an `objectExtensions` entry of the base\'s paired .custom package.'); + } + if (row?.disposition !== 'overridable') { + return refuse('declared_type_not_overridable', + `${item} carries \`overrides\`, but a ${type} takes no override (ADR-0146 D6). ` + + `Remove \`overrides\` and create your own ${type} under a new name ("new similar").`); + } + // `flow` and `action` are classified overridable and wait for D9's + // conditions (`isOpenOverrideType`): "flow customization is not yet + // available", and the same sentence for an action. + if (!isOpenOverrideType(type)) { + return refuse('declared_type_not_yet_available', + `${item} carries \`overrides\`: ${type} customization is not yet available, so a declared ${type} ` + + 'override is refused (ADR-0146 D9). Remove `overrides` and rename the item.'); + } + const base = declaration.base; + const pair = base === undefined ? undefined : customizationPackageId(base); + if (base === undefined || pair === undefined || !pair.ok || !isCustomizationPairOf(packageId, base)) { + 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}'` + : 'only the base\'s paired .custom package may declare an override, naming the base in `overrides.package`'; + return refuse('declared_outside_pair', + `${item} in ${who} carries \`overrides\`, and ${only}: a dependent package never replaces a base item ` + + '(ADR-0146 D2). Remove `overrides` and rename the item.'); + } + if (!(closure.dependencies ?? []).includes(base)) { + return refuse('declared_package_outside_closure', + `${item} declares an override of '${base}', which is outside the dependency closure of '${packageId}'. ` + + `Declare a dependency on '${base}' in the \`dependencies\` of '${packageId}''s manifest, or remove \`overrides\` (ADR-0146 D2).`); + } + if (!closure.shippers.includes(base)) { + return refuse('declared_package_ships_no_such_item', + `${item} declares an override of '${base}', which ships no ${type} named '${name}'. ` + + `Remove \`overrides\` to keep it as '${packageId}''s own item, or correct the name (ADR-0146 D2).`); + } + return { verdict: 'admit', base }; + } + + if (packageId === undefined || closure.shippers.length === 0) return NOT_CONCERNED; + if (getOverrideTypeClassification(type)?.disposition !== 'overridable') return NOT_CONCERNED; + const pairBase = customizationBasePackageId(packageId); + const base = pairBase !== null && closure.shippers.includes(pairBase) ? pairBase : closure.shippers[0]; + if (pairBase === base && isOpenOverrideType(type)) { + return refuse('undeclared_same_name', + `${item} in '${packageId}' bears the name of the ${type} '${base}' ships but declares no override. ` + + `Declare it, adding overrides: { package: '${base}', basedOn } with the version of '${base}' it was ` + + 'taken from, or rename the item (ADR-0146 D2).'); + } + const notYet = pairBase === base ? ` ${type} customization is not yet available.` : ''; + return refuse('undeclared_same_name', + `${item} in package '${packageId}' bears the name of the ${type} '${base}' ships, inside its dependency ` + + `closure.${notYet} A dependent package adds items under its own names and never replaces a base item. ` + + 'Rename the item (ADR-0146 D2).'); +} + +/** One item of a package body, as the override write gate names it. */ +export interface OverrideWriteItem { + /** The singular metadata type. */ + readonly type: string; + /** The name the gate's sentence carries. */ + readonly name: string; + /** + * The registry identity two packages collide on: an `action` is + * `:` (the registry's key), every other item its name. + */ + readonly identity: string; + /** The item body the declaration is read from. */ + readonly item: unknown; + /** Where it sits in the body, e.g. `dashboards[0]`, prefixed by the caller's `at`. */ + readonly path: string; +} + +const isPlainRecord = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +/** + * Every item a package body's collections carry, named the way the registry + * names it — the walk `os validate` and the marketplace install door hand + * {@link judgeOverrideWrite}, so the two cannot disagree on what an item is + * called. A `views:` container is the views it expands into + * (`expandViewContainer`, the loader's own expansion); an `action` is keyed + * `:`. A collection is an array or a name-keyed map. + */ +export function overrideWriteItemsOf(body: Record, at = ''): OverrideWriteItem[] { + const out: OverrideWriteItem[] = []; + for (const [plural, collection] of Object.entries(body)) { + const type = (PLURAL_TO_SINGULAR as Record)[plural]; + if (type === undefined || collection === null || typeof collection !== 'object') continue; + const entries: Array<[string, unknown, string | undefined]> = Array.isArray(collection) + ? collection.map((item, i): [string, unknown, string | undefined] => [`${at}${plural}[${i}]`, item, undefined]) + : Object.entries(collection).map(([key, item]): [string, unknown, string | undefined] => [`${at}${plural}.${key}`, item, key]); + for (const [path, item, key] of entries) { + if (!isPlainRecord(item)) continue; + const declaredName = typeof item.name === 'string' && item.name !== '' ? item.name : key; + if (declaredName === undefined) continue; + if (type === 'view' && isAggregatedViewContainer(item)) { + const object = typeof item.object === 'string' && item.object !== '' ? item.object : declaredName; + for (const view of expandViewContainer(object, item)) { + out.push({ type, name: view.name, identity: view.name, item: view.config, path }); + } + continue; + } + const identity = type === 'action' && typeof item.objectName === 'string' && item.objectName !== '' + ? `${item.objectName}:${declaredName}` + : declaredName; + out.push({ type, name: declaredName, identity, item, path }); + } + } + return out; +} + +/** + * The thrown form of a `refuse` verdict: `403 NOT_OVERRIDABLE`, the code and + * status the sealed write already answers, so no door gains a code. + */ +export function overrideWriteRefusalError( + verdict: Extract, +): Error { + const err = new Error(verdict.message); + (err as any).code = 'NOT_OVERRIDABLE'; + (err as any).status = 403; + return err; +} + +/** + * The body of `ObjectStackProtocolImplementation#overrideWriteVerdict`; the + * method's docblock there is its contract. Reads the closure from the + * registry (`resolveWritePackageScope`, the save door's own) and the shipping + * packages from the artifact lookup (`shippedArtifactsOf`, the read's own), + * then asks {@link judgeOverrideWrite}. + */ +export function overrideWriteVerdict( + this: Pick, + request: OverrideWriteSubject, +): OverrideWriteVerdict { + const packageId = typeof request.packageId === 'string' && request.packageId.length > 0 + ? request.packageId + : undefined; + const declaration = declarationOf(request.item); + if (packageId === undefined && !declaration.present) return NOT_CONCERNED; + const scope = packageId === undefined ? undefined : this.resolveWritePackageScope(packageId); + // A named package the registry cannot produce is not this gate's to + // judge: both doors ask `uninstalledNamedBaseRefusal`, which refuses a + // write naming an uninstalled base (`WRITABLE_PACKAGE_REQUIRED`) — the + // fact about the package, not about the item. + if (packageId !== undefined && scope === undefined) return NOT_CONCERNED; + const dependencies = scope?.dependencies.filter((id) => id !== packageId); + const shippers = dependencies === undefined || dependencies.length === 0 + ? [] + : Array.from(new Set( + this.shippedArtifactsOf(request.type, request.name) + .map((a) => (a as { _packageId?: unknown } | null | undefined)?._packageId) + // An override replaces an item of an INSTALLED, read-only base + // (ADR-0146 D1): a writable package's item is edited in place + // (ADR-0070), so a writable shipper is no base this gate guards. + // Every artifact a kernel serves is registered read-only + // (`registerApp` → `manifests`), so this keeps no real base out. + .filter((id): id is string => typeof id === 'string' && dependencies.includes(id) + && !this.isWritablePackage(id)), + )); + return judgeOverrideWrite(request, { dependencies, shippers }); +} + + /** The body of `ObjectStackProtocolImplementation#originGatedRemovalRefusal`; the method's docblock there is its contract. */ export function originGatedRemovalRefusal( this: Pick, 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 b0e3403b764..afc83180017 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -27,6 +27,7 @@ * this package. */ import { afterEach, describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; @@ -276,7 +277,12 @@ describe('packagedBaseRefusal — the sentence is chosen per ADR-0126 regime', ( const p = protocolOn('env_1'); const refusal: any = p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }); expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(refusal.message).toBe(SEALED_SAVE('page', 'pkg_page')); + // [ADR-0146 D2] …and, on a type a declared override can replace, names + // the two fixes: declare from the base's paired `.custom`, or rename. + expect(refusal.message).toBe( + `${SEALED_SAVE('page', 'pkg_page')} To change it, declare an override from '${PACKAGE_ID}.custom' ` + + `(overrides: { package: '${PACKAGE_ID}', basedOn }), or rename the item.`, + ); // It never names the hatch as a remedy. expect(refusal.message).not.toMatch(/set OS_METADATA_WRITABLE|may set OS_METADATA_WRITABLE/); }); @@ -446,3 +452,418 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa } }); }); + +// ───────────────────────────────────────────────────────────────────────── +// ADR-0146 D2 (ruling A on #28390) — the override write gate, at publish. +// ───────────────────────────────────────────────────────────────────────── + +/** + * The override write gate: only the base's paired `.custom` declares an + * override. Inside a base's dependency closure, a dependent's item that bears + * a base item's name is refused unless it is the pair's declared override; a + * dependent that carries `overrides` is refused; a declaration naming a + * package outside the closure, or a package that ships no such item, is + * refused; and `overrides` on a type with no override channel is refused by + * name. Each refusal is `403 NOT_OVERRIDABLE` (the code the sealed write + * already answers) with its own sentence. + * + * Harness: the real save and promotion paths over a stub engine whose + * registry ships the base's artifacts (`_packageId` stamped, as + * `SchemaRegistry.getArtifactItem` stamps a code package's) and serves each + * package's manifest for the closure walk. + */ +describe('the override write gate — only the base\'s paired .custom declares an override (ADR-0146 D2)', () => { + const BASE = 'com.acme.crm'; + const PAIR = 'com.acme.crm.custom'; + const PARTNER = 'com.acme.partner'; + const OTHER_PAIR = 'com.zeta.custom'; + const UNRELATED = 'com.other'; + const MANIFESTS: Record; version: string }> = { + [BASE]: { dependencies: {}, version: '3.2.0' }, + [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0' }, + [PARTNER]: { dependencies: { [BASE]: '^3' }, version: '1.0.0' }, + // A pair of ANOTHER base that also depends on this one: the former + // "second declarer" — not the base's pair, so it may not declare. + [OTHER_PAIR]: { dependencies: { [BASE]: '^3', 'com.zeta': '^1' }, version: '0.1.0' }, + 'com.zeta': { dependencies: {}, version: '1.0.0' }, + [UNRELATED]: { dependencies: {}, version: '1.0.0' }, + // The pair of the unrelated package, installed without declaring its base. + [`${UNRELATED}.custom`]: { dependencies: {}, version: '0.1.0' }, + }; + /** What the base ships: an overlay type, a sealed type, a flow, an action, a permission set, an object. */ + const BASE_ITEMS: Array<{ type: string; name: string }> = [ + { type: 'view', name: 'task.crm_list' }, + { type: 'dashboard', name: 'crm_board' }, + { type: 'page', name: 'crm_home' }, + { type: 'flow', name: 'crm_flow' }, + { type: 'action', name: 'crm_action' }, + { type: 'permission', name: 'crm_perm' }, + { type: 'object', name: 'crm_account' }, + ]; + const artifact = (type: string, name: string) => + BASE_ITEMS.some((i) => i.type === type && i.name === name) + ? { name, label: name, _packageId: BASE, _provenance: 'package' } + : undefined; + + interface GateRow { type: string; name: string; package_id: string | null; state: string; metadata: string; id?: string } + const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + const matches = (r: GateRow, where: Record): boolean => Object.entries(where).every(([k, v]) => { + if (v === undefined) return true; + if (k === '$or') return (v as Array>).some((c) => matches(r, c)); + if (v !== null && typeof v === 'object' && Array.isArray((v as { $in?: unknown }).$in)) { + return (v as { $in: unknown[] }).$in.includes((r as unknown as Record)[k]); + } + return (r as unknown as Record)[k] === v; + }); + + function gateProtocol() { + const rows = new Map(); + let nextId = 0; + const findRow = (w: Record) => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const r = rows.get(keyOf(w)); + if (r) return { key: keyOf(w), row: r }; + } + for (const [k, r] of rows) if (matches(r, w)) return { key: k, row: r }; + return null; + }; + const packageRecord = (id: string) => + (MANIFESTS[id] ? { manifest: { id, ...MANIFESTS[id] } } : undefined); + const engine: any = { + // A booted code package is read-only (`isWritablePackage` reads `manifests` first). + manifests: new Map([[BASE, {}]]), + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const hits = Array.from(rows.values()).filter((r) => matches(r, opts?.where ?? {})); + return typeof opts?.limit === 'number' ? hits.slice(0, opts.limit) : hits; + }, + async insert(table: string, data: Record) { + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { ...(data as unknown as GateRow), id: `r_${nextId}` }; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as unknown as GateRow) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { return true; }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + listItems: (type: string) => (type === 'object' + ? [{ name: 'task', fields: { name: { type: 'text' }, title: { type: 'text' } } }, + { ...artifact('object', 'crm_account'), fields: { name: { type: 'text' } } }] + : BASE_ITEMS.filter((i) => i.type === type).map((i) => artifact(i.type, i.name))), + getItem: () => undefined, + getObject: () => undefined, + getPackage: packageRecord, + getAllPackages: () => Object.keys(MANIFESTS).map(packageRecord), + isPackageDisabled: () => false, + getArtifactItem: (type: string, name: string) => artifact(type, name), + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1') as any; + /** Store a draft as one saved before this gate existed would sit. */ + const seedDraft = (type: string, name: string, packageId: string, body: Record) => { + nextId += 1; + const row: GateRow = { type, name, package_id: packageId, state: 'draft', metadata: JSON.stringify(body), id: `r_${nextId}` }; + rows.set(keyOf(row as unknown as Record), row); + }; + const stored = () => Array.from(rows.values()) + .map((r) => ({ type: r.type, name: r.name, package_id: r.package_id, state: r.state })); + return { protocol, rows, stored, seedDraft }; + } + + const DECLARATION = { package: BASE, basedOn: '3.2.0' }; + const BODIES: Record Record> = { + view: (name) => ({ name, label: 'List', object: 'task', viewKind: 'list', columns: [{ field: 'title', label: 'Title' }] }), + dashboard: (name) => ({ name, label: 'Board', widgets: [] }), + page: (name) => ({ name, label: 'Home', object: 'task', type: 'list', kind: 'full', regions: [] }), + flow: (name) => ({ name, label: 'Flow', type: 'autolaunched', nodes: [], edges: [] }), + action: (name) => ({ name, label: 'Action', type: 'script', body: { language: 'js', source: 'return { success: true };' } }), + permission: (name) => ({ name, label: 'Perm', objects: {} }), + object: (name) => ({ name, label: 'Account', pluralLabel: 'Accounts', fields: {}, sharingModel: 'private' }), + }; + const body = (type: string, name: string, declared = false, declaration: Record = DECLARATION) => + ({ ...BODIES[type](name), ...(declared ? { overrides: { ...declaration } } : {}) }); + const thrown = (run: Promise) => run.then(() => undefined, (e: any) => e); + const REFUSED = { code: 'NOT_OVERRIDABLE', status: 403 }; + + // ── (a) an undeclared same name inside the closure ────────────────────── + + for (const [type, name] of [['view', 'task.crm_list'], ['dashboard', 'crm_board']] as const) { + it(`a partner's undeclared same-named ${type} is refused — rename (the #28238 shape: today 200)`, async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type, name, packageId: PARTNER, item: body(type, name) })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toContain(`'${type}/${name}'`); + expect(String(e.message)).toContain(BASE); + expect(String(e.message)).toMatch(/Rename the item/); + expect(String(e.message)).not.toMatch(/declare/i); + expect(stored()).toEqual([]); + }); + + it(`the pair's undeclared same-named ${type} is refused — declare or rename`, async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type, name, packageId: PAIR, item: body(type, name) })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toContain(`overrides: { package: '${BASE}'`); + expect(String(e.message)).toMatch(/or rename the item/); + expect(stored()).toEqual([]); + }); + } + + it('an undeclared same name is refused on a draft save too, and nothing is stored', async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ + type: 'view', name: 'task.crm_list', packageId: PARTNER, item: body('view', 'task.crm_list'), mode: 'draft', + })); + expect(shape(e)).toEqual(REFUSED); + expect(stored()).toEqual([]); + }); + + it('control: outside the closure a same name coexists — an unrelated package keeps its own view', async () => { + const { protocol, stored } = gateProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', packageId: UNRELATED, item: body('view', 'task.crm_list') }); + expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: UNRELATED, state: 'active' }]); + }); + + it('control: a package-less overlay row is not this gate\'s question (the migration card closes it)', async () => { + const { protocol, stored } = gateProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', item: body('view', 'task.crm_list') }); + expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: null, state: 'active' }]); + }); + + // ── the admit ──────────────────────────────────────────────────────────── + + for (const [type, name] of [['view', 'task.crm_list'], ['dashboard', 'crm_board'], ['page', 'crm_home']] as const) { + it(`the pair's declared override of a ${type} is admitted and stored with its declaration`, async () => { + const { protocol, rows, stored } = gateProtocol(); + await protocol.saveMetaItem({ type, name, packageId: PAIR, item: body(type, name, true) }); + expect(stored()).toEqual([{ type, name, package_id: PAIR, state: 'active' }]); + const row = Array.from(rows.values())[0]; + expect(JSON.parse(row.metadata).overrides).toEqual(DECLARATION); + }); + } + + // ── (b) `overrides` on a package that is not the base's pair ──────────── + + for (const pkg of [PARTNER, OTHER_PAIR]) { + it(`${pkg === PARTNER ? 'a partner' : 'another base\'s pair'} that carries \`overrides\` is refused — only the pair declares`, async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ + type: 'view', name: 'task.crm_list', packageId: pkg, item: body('view', 'task.crm_list', true), + })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toContain(`only '${PAIR}' may declare`); + expect(stored()).toEqual([]); + }); + } + + it('the second declarer: after the pair declared, a partner declaring the same item is refused', async () => { + const { protocol, stored } = gateProtocol(); + await protocol.saveMetaItem({ type: 'view', name: 'task.crm_list', packageId: PAIR, item: body('view', 'task.crm_list', true) }); + const e = await thrown(protocol.saveMetaItem({ + type: 'view', name: 'task.crm_list', packageId: PARTNER, item: body('view', 'task.crm_list', true), + })); + expect(shape(e)).toEqual(REFUSED); + expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: PAIR, state: 'active' }]); + }); + + // ── (c) a declaration naming the wrong package ────────────────────────── + + it('a declaration naming a package outside the closure is refused — declare a dependency', async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ + type: 'view', name: 'task.crm_list', packageId: `${UNRELATED}.custom`, + item: body('view', 'task.crm_list', true, { package: UNRELATED, basedOn: '1.0.0' }), + })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toMatch(/Declare a dependency on/); + expect(stored()).toEqual([]); + }); + + it('a declaration naming a base that ships no such item is refused', async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ + type: 'view', name: 'task.novel', packageId: PAIR, item: body('view', 'task.novel', true), + })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toContain(`ships no view named 'task.novel'`); + expect(stored()).toEqual([]); + }); + + // ── (d) an excluded type, (e) an object, flow and action ──────────────── + + it('`overrides` on a permission set is refused — new similar (ADR-0146 D6)', async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type: 'permission', name: 'crm_perm', packageId: PAIR, item: body('permission', 'crm_perm', true) })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toMatch(/takes no override/); + expect(String(e.message)).toMatch(/new similar/); + expect(stored()).toEqual([]); + }); + + it('`overrides` on an object is refused — extend it (ADR-0146 D10)', async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type: 'object', name: 'crm_account', packageId: PAIR, item: body('object', 'crm_account', true) })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toMatch(/extended, never replaced/); + expect(String(e.message)).toContain('objectExtensions'); + expect(stored()).toEqual([]); + }); + + it('a declared flow override is refused — flow customization is not yet available', async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type: 'flow', name: 'crm_flow', packageId: PAIR, item: body('flow', 'crm_flow', true) })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toMatch(/flow customization is not yet available/); + expect(stored()).toEqual([]); + }); + + it('a declared action override is refused — action customization is not yet available', async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type: 'action', name: 'crm_action', packageId: PAIR, item: body('action', 'crm_action', true) })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toMatch(/action customization is not yet available/); + expect(stored()).toEqual([]); + }); + + // ── the sealed write's sentence ───────────────────────────────────────── + + it('the pair\'s sealed write of an overridable type says declare or rename, not clone; code and status stay', async () => { + const { protocol } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type: 'page', name: 'crm_home', packageId: PAIR, item: body('page', 'crm_home') })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toMatch(/ To change it, declare an override from '[^']+' \(overrides: \{ package: '[^']+', basedOn \}\), or rename the item\.$/); + expect(String(e.message)).toContain(`declare an override from '${PAIR}' (overrides: { package: '${BASE}', basedOn })`); + expect(String(e.message).toLowerCase()).not.toContain('clone'); + expect(String(e.message).length).toBeLessThan(500); + }); + + it('any other package\'s sealed write of an overridable type says rename only — only the pair declares (#28390 A)', async () => { + // A non-pair package that declared would meet `declared_outside_pair`, + // so the sealed sentence never advises it to declare. + const { protocol } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type: 'page', name: 'crm_home', packageId: UNRELATED, item: body('page', 'crm_home') })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message).endsWith(` To change it, rename the item: only '${PAIR}' may declare an override of '${BASE}'.`)).toBe(true); + expect(String(e.message)).not.toContain('declare an override from'); + expect(String(e.message)).not.toMatch(/basedOn/); + expect(String(e.message).toLowerCase()).not.toContain('clone'); + }); + + it('a sealed write naming no package keeps the declare-or-rename advice (the administrator may move it to the pair)', async () => { + const { protocol } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ type: 'page', name: 'crm_home', item: body('page', 'crm_home') })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toContain(`declare an override from '${PAIR}' (overrides: { package: '${BASE}', basedOn }), or rename the item.`); + }); + + // ── a malformed `overrides` is the schema gate's, not this gate's ─────── + // (should-fix 3 of the contract review on #28554) + + for (const [label, overrides] of [ + ['null', null], + ['a string', 'com.acme.crm'], + ['no package', { basedOn: '1.0.0' }], + ['an empty package', { package: '', basedOn: '1.0.0' }], + ['no basedOn', { package: 'com.acme.crm' }], + ] as const) { + it(`a partner's view whose \`overrides\` is malformed (${label}) is not judged as a declaration: no declared_outside_pair`, async () => { + const { protocol, stored } = gateProtocol(); + const e = await thrown(protocol.saveMetaItem({ + type: 'view', name: 'partner_own_list', packageId: PARTNER, + item: { ...body('view', 'partner_own_list'), overrides }, + })); + // The door's schema gate answers the malformed value (its + // `INVALID_METADATA` / 422, naming the key); the override gate + // never answers `403 NOT_OVERRIDABLE` for it. + expect(shape(e)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(String(e.message)).toMatch(/overrides/); + expect(String(e.message)).not.toMatch(/carries `overrides`/); + expect(stored()).toEqual([]); + }); + } + + // ── the promotion doors: a draft stored before the gate ───────────────── + + it('publishMetaItem refuses a partner\'s undeclared same-named draft, and promotes nothing', async () => { + const { protocol, stored, seedDraft } = gateProtocol(); + seedDraft('view', 'task.crm_list', PARTNER, body('view', 'task.crm_list')); + const e = await thrown(protocol.publishMetaItem({ type: 'view', name: 'task.crm_list', packageId: PARTNER })); + expect(shape(e)).toEqual(REFUSED); + expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: PARTNER, state: 'draft' }]); + }); + + it('publishMetaItem refuses a partner\'s draft that carries `overrides`', async () => { + const { protocol, stored, seedDraft } = gateProtocol(); + seedDraft('view', 'task.crm_list', PARTNER, body('view', 'task.crm_list', true)); + const e = await thrown(protocol.publishMetaItem({ type: 'view', name: 'task.crm_list', packageId: PARTNER })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toContain(`only '${PAIR}' may declare`); + expect(stored()).toEqual([{ type: 'view', name: 'task.crm_list', package_id: PARTNER, state: 'draft' }]); + }); + + it('publishMetaItem promotes the pair\'s declared override of a sealed type', async () => { + const { protocol, stored, seedDraft } = gateProtocol(); + seedDraft('page', 'crm_home', PAIR, body('page', 'crm_home', true)); + await protocol.publishMetaItem({ type: 'page', name: 'crm_home', packageId: PAIR }); + expect(stored()).toEqual([{ type: 'page', name: 'crm_home', package_id: PAIR, state: 'active' }]); + }); + + it('publishPackageDrafts refuses a partner\'s undeclared same-named draft, and promotes nothing', async () => { + // The batch answers per item and rolls the whole batch back (ADR-0067 + // D2); this stub's transaction does not roll back, so what is pinned + // is the refused item: never promoted, named with its code. + const { protocol, stored, seedDraft } = gateProtocol(); + seedDraft('dashboard', 'partner_board', PARTNER, body('dashboard', 'partner_board')); + seedDraft('dashboard', 'crm_board', PARTNER, body('dashboard', 'crm_board')); + const res = await protocol.publishPackageDrafts({ packageId: PARTNER }); + expect(res.success).toBe(false); + expect(res.failed).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'dashboard', name: 'crm_board', code: 'NOT_OVERRIDABLE' }), + ])); + expect(stored().filter((r) => r.name === 'crm_board')).toEqual([ + { type: 'dashboard', name: 'crm_board', package_id: PARTNER, state: 'draft' }, + ]); + }); + + it('publishPackageDrafts promotes the pair\'s declared override beside its own new item', async () => { + const { protocol, stored, seedDraft } = gateProtocol(); + seedDraft('page', 'crm_home', PAIR, body('page', 'crm_home', true)); + seedDraft('dashboard', 'custom_board', PAIR, body('dashboard', 'custom_board')); + const res = await protocol.publishPackageDrafts({ packageId: PAIR }); + expect(res.success).toBe(true); + expect(stored().map((r) => `${r.type}/${r.name}:${r.state}`).sort()) + .toEqual(['dashboard/custom_board:active', 'page/crm_home:active']); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts b/packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts index bfb4dbc8f7c..eeea53d6b03 100644 --- a/packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts +++ b/packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts @@ -12,7 +12,9 @@ * The sentence states what the door knows and where the names are listed: * "'TYPE/NAME' is provided by a managed package and is sealed: it cannot be * written here. GET /api/v1/meta/TYPE lists the names in use." The wording is - * the ruled text, so it is pinned whole for a type with no regime row. A type + * the ruled text, so it is pinned whole for a type with no regime row; on a + * type a declared override can replace it gains the declare-or-rename sentence + * (ADR-0146 D2, #28410). A type * with a regime row keeps its regime sentence (ADR-0126 §2: a Regime C refusal * names its sanctioned path; the origin-gated row names the source a * code-defined datasource is declared in). @@ -29,7 +31,7 @@ */ import { describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { DEFAULT_METADATA_TYPE_REGISTRY, getOverrideTypeClassification } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { PACKAGED_BASE_REGIME } from './packaged-base-regime.js'; @@ -64,10 +66,23 @@ const TYPES = [...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), PLUGIN_TYPE /** The type's listing route — where the names in use are read. */ const listingOf = (type: string) => `GET /api/v1/meta/${type}`; +/** + * [ADR-0146 D2, #28410] The sealed write's declare-or-rename sentence, on a + * type whose override channel is open (overridable, waiting for no D9 + * condition), read from the closed list, never transcribed. + */ +const declareOrRename = (type: string) => { + const row = getOverrideTypeClassification(type); + return row?.disposition === 'overridable' && (row.conditions?.length ?? 0) === 0 + ? ` To change it, declare an override from '${PACKAGE_ID}.custom' ` + + `(overrides: { package: '${PACKAGE_ID}', basedOn }), or rename the item.` + : ''; +}; + /** The ruled sentence, for a type with no regime row. */ const oneSentence = (type: string, name: string) => `'${type}/${name}' is provided by a managed package and is sealed: it cannot be written here. ` - + `${listingOf(type)} lists the names in use.`; + + `${listingOf(type)} lists the names in use.${declareOrRename(type)}`; /** Advice the door cannot give truthfully: a create's, and the source artifact's. */ const CREATE_ADVICE = /choose a name|name no package|author your own/i; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e9892b4eb67..5cb701458f4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -386,7 +386,13 @@ import { applyPublishBackfill, planPublishBackfill } from './protocol-publish-ba // [#25467 cut 12] The `_drafts` list read and the one-draft discard. import { discardDraftInItsScope, listDrafts } from './protocol-drafts.js'; // [#25467 cut 13] The package door's save refusal and the origin-gated removal verdict. -import { originGatedRemovalRefusal, refusePackagedBaseOverride } from './protocol-packaged-base-refusal.js'; +import { + originGatedRemovalRefusal, + overrideWriteRefusalError, + overrideWriteVerdict, + refusePackagedBaseOverride, +} from './protocol-packaged-base-refusal.js'; +import type { OverrideWriteSubject, OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; // [#25467 cut 14] The publish batch's seed apply and its own pending declarations. import { applySeedBodies, collectBatchPendingDeclarations } from './protocol-publish-batch.js'; // [#25467 cut 15] The artifact registry's lookups: an item's artifact, the packages that ship a type, and the lock's artifact layer. @@ -16871,10 +16877,35 @@ export class ObjectStackProtocolImplementation implements const host: ThisParameterType = { isSealedManagedItem: (...a) => this.isSealedManagedItem(...a), isWritablePackage: (...a) => this.isWritablePackage(...a), + packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a), }; return refusePackagedBaseOverride.call(host, request); } + /** + * [ADR-0146 D2, ruling A on #28390] The override write gate's verdict on + * one item written into one package: `admit` for the base's paired + * `.custom` declaring an override of an item the base ships, one + * named `refuse`, or `not_concerned`. The rule is + * `judgeOverrideWrite` (`protocol-packaged-base-refusal.ts`), the one + * judgement `os validate` and the marketplace install door ask too; this + * method supplies its two registry facts — the written package's + * dependency closure ({@link resolveWritePackageScope}) and which closure + * packages ship the name ({@link shippedArtifactsOf}). + * + * Asked by the save door ahead of {@link refusePackagedBaseOverride}, and + * by the promotion ({@link promoteDraftForPublish}, so both + * `publishMetaItem` and `publishPackageDrafts`) on the draft's body. + */ + private overrideWriteVerdict(request: OverrideWriteSubject): OverrideWriteVerdict { + const host: ThisParameterType = { + isWritablePackage: (...a) => this.isWritablePackage(...a), + resolveWritePackageScope: (...a) => this.resolveWritePackageScope(...a), + shippedArtifactsOf: (...a) => this.shippedArtifactsOf(...a), + }; + return overrideWriteVerdict.call(host, request); + } + /** * `deleteMetaItem`'s refusal of a removal of an item a code package ships, * on a type with no per-org overlay channel — EXCEPT a type whose loader @@ -20881,6 +20912,27 @@ export class ObjectStackProtocolImplementation implements // are no caller's claim about anything: a row stored before the // #16702 strip may still carry stale stamps, and rewriting it keeps // the silent strip it always had rather than failing the rewrite. + // [ADR-0146 D2, ruling A on #28390] The override write gate — on every + // save, whatever its source, and ahead of the flow-only sealed check + // below, so a declared or same-named item meets its own named + // refusal rather than a regime sentence: only the base's paired + // `.custom` declares an override; a dependent's same-named item + // is refused (rename, or for the pair declare-or-rename); `overrides` + // on a dependent, on a package outside the closure or on a type with + // no override channel is refused. An admitted declaration is the + // pair's own row: the sealed refusal does not apply to it, and the + // store is told a runtime-only write (both below). + // + // A refused DECLARATION throws here. An undeclared same name throws + // just before the store is asked (below), after every refusal this + // door already answered for that write, so no existing refusal's code + // or status changes (a view container's name collision keeps its + // 400, a lock its ITEM_LOCKED). + const overrideVerdict = this.overrideWriteVerdict(request); + if (overrideVerdict.verdict === 'refuse' && overrideVerdict.rule !== 'undeclared_same_name') { + throw overrideWriteRefusalError(overrideVerdict); + } + const declaredOverride = overrideVerdict.verdict === 'admit'; if (request.source === undefined && request.writeFace !== 'package-duplicate') { const authored = await this.tenantAuthoredWriteRefusal({ type: request.type, @@ -21057,7 +21109,10 @@ export class ObjectStackProtocolImplementation implements // [#23577] Every write over a sealed name reads one sentence: the // first-write pin (`If-None-Match: *`) declares no create, so the door // advises neither a create nor an edit. - this.refusePackagedBaseOverride(request); + // [ADR-0146 D2] An admitted declared override (see the gate above) is + // the pair's own row: the sealed refusal judges the base's managed + // item, so it does not apply to it. + if (!declaredOverride) this.refusePackagedBaseOverride(request); // ADR-0010 L3 — per-item lock. Artifact `_lock` (or persisted // overlay `_lock`) blocks save independent of the L1 type-level @@ -22041,9 +22096,37 @@ export class ObjectStackProtocolImplementation implements // Callers that pass an explicit `parentVersion` (e.g. Studio after // reading an item) get true optimistic-lock conflict detection // surfaced as a 409. + // [ADR-0146 D2] The override write gate's undeclared-same-name + // refusal (see the gate above): the last refusal before the store. + // + // A save naming no package was judged above as package-less (`not + // concerned` for an undeclared item), but its address may have given + // it a package since: the row it lands in, as the package-less address + // resolved it (publish) or the draft address names it (draft). Judged + // again here for THAT package, so a package-less save cannot write a + // dependent's undeclared same-named row the same save naming the + // dependent is refused. A declaration never reaches this re-judgement: + // a package-less declaration was refused above. + let lateOverrideVerdict = overrideVerdict; + if ( + namedNoPackage + && overrideVerdict.verdict === 'not_concerned' + && request.source === undefined + && request.writeFace !== 'package-duplicate' + ) { + const inheritedPackage = mode === 'draft' + ? draftAddress && draftAddress.kind !== 'ambiguous' ? draftAddress.into : null + : request.packageId ?? null; + if (typeof inheritedPackage === 'string' && inheritedPackage.length > 0) { + lateOverrideVerdict = this.overrideWriteVerdict({ ...request, packageId: inheritedPackage }); + } + } + if (lateOverrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(lateOverrideVerdict); const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type; const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name); - const intent: 'override-artifact' | 'runtime-only' = artifactBacked + // [ADR-0146 D2] An admitted declared override writes the pair's own + // row; it edits no managed item, so the store judges a runtime write. + const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !declaredOverride ? 'override-artifact' : 'runtime-only'; // D1 (ADR-0070) — a brand-new, DB-only ("runtime-only") metadata @@ -23607,6 +23690,24 @@ export class ObjectStackProtocolImplementation implements ); if (nameCollision) throw nameCollision; } + // [ADR-0146 D2] …and the override write gate, on the draft's body and + // the package it is promoted into, with the save door's judge and + // envelope: a draft stored before the gate (or under a package whose + // closure has since gained the name) is refused here, before + // `repo.promoteDraft` writes, so a batch publish aborts on it as on the + // refusals above. An admitted declaration is promoted as the pair's own + // row (the runtime-only intent below). + // Judged here and thrown just before the store promotes (below), so no + // refusal this door already answered changes its code. (The save door + // judged this draft when it was stored; a refusal here is a draft + // stored before the gate, or one whose closure has since gained the + // name.) + const overrideVerdict: OverrideWriteVerdict = draftForGate + ? this.overrideWriteVerdict({ + type: singularType, name: request.name, packageId: promoteInto, item: draftForGate.body, + }) + : { verdict: 'not_concerned' }; + const declaredOverride = overrideVerdict.verdict === 'admit'; // [#23015] …and one default record page per object per deployment: a // draft saved while no other page held its object's default can meet // one by the time it is published, since the save door judged only the @@ -23718,8 +23819,10 @@ export class ObjectStackProtocolImplementation implements }) : []; + // [ADR-0146 D2] The override write gate's refusal, the last before the store. + if (overrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(overrideVerdict); const artifactBacked = this.isArtifactBacked(singularType, request.name); - const intent: 'override-artifact' | 'runtime-only' = artifactBacked + const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !declaredOverride ? 'override-artifact' : 'runtime-only'; const ref = { type: singularType, 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 9809da51c7b..88180e0429d 100644 --- a/packages/objectql/src/protocol-custom-override-effective-read.test.ts +++ b/packages/objectql/src/protocol-custom-override-effective-read.test.ts @@ -249,6 +249,34 @@ async function saveInto( await protocol.publishMetaItem({ type: 'view', name: VIEW, packageId } as any); } +/** + * [#28410, ADR-0146 D2, ruling A on #28390] A row the override write gate now + * refuses at the save door — a dependent package's (or the pair's undeclared) + * row under the name X ships, or a declaration from a package that is not X's + * pair — laid down as one stored BEFORE that gate: the door's refusal is + * asserted first, then the same body goes through the same draft save and + * per-item publish with the gate's verdict held at `not_concerned` for that + * one write. The read pins below are about such rows, which storage still + * holds. + */ +async function saveIntoBeforeTheGate( + protocol: ObjectStackProtocolImplementation, + packageId: string, + columns: readonly string[], + extra: Record = {}, +): Promise { + const body = { ...listBody(columns), name: VIEW, ...extra }; + await expect(protocol.saveMetaItem({ type: 'view', name: VIEW, item: body, mode: 'draft', packageId } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + const gated = protocol as any; + gated.overrideWriteVerdict = () => ({ verdict: 'not_concerned' }); + try { + await saveInto(protocol, packageId, columns, extra); + } finally { + delete gated.overrideWriteVerdict; + } +} + /** An active row laid down through the engine as the repository's write lays one down: package-unbound, or bound as given. */ const storeRow = (engine: ObjectQL, columns: readonly string[], packageId: string | null = null, extra: Record = {}) => engine.insert('sys_metadata', { @@ -306,7 +334,7 @@ describe('[#28409] the five reads of a name X ships serve X\'s declared `.custom it('a declaration from a package that is not X\'s pair is ignored: every read of X\'s name serves X\'s document', async () => { const { protocol } = await boot(); await install(protocol, DEPENDENT); - await saveInto(protocol, DEPENDENT, DEPENDENT_COLUMNS, { overrides: { ...DECLARATION } }); + await saveIntoBeforeTheGate(protocol, DEPENDENT, DEPENDENT_COLUMNS, { overrides: { ...DECLARATION } }); expect(columnsOf(await read(protocol))).toEqual(BASE_COLUMNS); expect(columnsOf(await read(protocol, BASE))).toEqual(BASE_COLUMNS); @@ -320,7 +348,7 @@ describe('[#28409] the five reads of a name X ships serve X\'s declared `.custom it('an undeclared row of the pair is the pair\'s own item, not the override', async () => { const { protocol } = await boot(); await install(protocol, PAIR); - await saveInto(protocol, PAIR, OVERRIDE_COLUMNS); + await saveIntoBeforeTheGate(protocol, PAIR, OVERRIDE_COLUMNS); expect(columnsOf(await read(protocol))).toEqual(BASE_COLUMNS); expect(columnsOf(await read(protocol, BASE))).toEqual(BASE_COLUMNS); @@ -333,7 +361,7 @@ describe('[#28409] the env-wide list joins the one read', () => { it('a dependent package\'s same-named row no longer replaces X\'s item; the per-package layer still holds it', async () => { const { protocol } = await boot(); await install(protocol, DEPENDENT); - await saveInto(protocol, DEPENDENT, DEPENDENT_COLUMNS); + await saveIntoBeforeTheGate(protocol, DEPENDENT, DEPENDENT_COLUMNS); expect(await listed(protocol)).toEqual([[BASE, BASE_COLUMNS]]); expect(await listed(protocol, BASE)).toEqual([[BASE, BASE_COLUMNS]]); @@ -346,7 +374,7 @@ describe('[#28409] the env-wide list joins the one read', () => { it('a package-unbound row keeps winning over X\'s code item in the list, beside a dependent package\'s row', async () => { const { engine, protocol } = await boot(); await install(protocol, DEPENDENT); - await saveInto(protocol, DEPENDENT, DEPENDENT_COLUMNS); + await saveIntoBeforeTheGate(protocol, DEPENDENT, DEPENDENT_COLUMNS); await storeUnboundRow(engine, UNBOUND_COLUMNS); expect((await listed(protocol)).map(([, columns]) => columns)).toEqual([UNBOUND_COLUMNS]); @@ -392,7 +420,7 @@ describe('[#28409] orphans of X\'s name are reported, never dropped silently', ( it(`${label}: the layered read reports it in \`overlay\`, \`effective\` = X's document`, async () => { const { protocol } = await boot(); await install(protocol, owner); - await saveInto(protocol, owner, DEPENDENT_COLUMNS); + await saveIntoBeforeTheGate(protocol, owner, DEPENDENT_COLUMNS); for (const scope of [undefined, BASE]) { const layers = await layered(protocol, scope); diff --git a/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts b/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts index 18fb9ffb89e..3f41fb1bcd7 100644 --- a/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts +++ b/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts @@ -22,8 +22,9 @@ * * - the card's three reads answer base / dependent / base, and the layered * read's `effective` and the list scoped to the base agree with them; - * - the write address does not move: a save naming no package still writes - * the dependent package's row, as {@link packagelessAddress} resolves it; + * - the write address does not move: a save naming no package still + * addresses the dependent package's row, as {@link packagelessAddress} + * resolves it — and since #28410 that write is refused (ADR-0146 D2); * - CONTROL: a writable package's OWN item, a name no code package ships, * is still served to the read naming no package (#23291); * - CONTROL: a package-unbound row (the ADR-0005 overlay Setup writes) still @@ -226,6 +227,26 @@ async function saveIntoDependent(protocol: ObjectStackProtocolImplementation, na await protocol.publishMetaItem({ type: 'view', name, packageId: DEPENDENT } as any); } +/** + * [#28410, ADR-0146 D2, ruling A on #28390] The card's step 2 under a name the + * base ships is refused at the save door now (a dependent package's + * same-named item has one fix, rename), so the dependent's row the read pins + * below meet is one stored BEFORE that gate: the door's refusal is asserted + * first, then the same draft save and per-item publish run with the gate's + * verdict held at `not_concerned` for that one write. + */ +async function saveIntoDependentBeforeTheGate(protocol: ObjectStackProtocolImplementation, columns: readonly string[]): Promise { + await expect(protocol.saveMetaItem({ type: 'view', name: VIEW, item: { ...listBody(columns), name: VIEW }, mode: 'draft', packageId: DEPENDENT } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + const gated = protocol as any; + gated.overrideWriteVerdict = () => ({ verdict: 'not_concerned' }); + try { + await saveIntoDependent(protocol, VIEW, columns); + } finally { + delete gated.overrideWriteVerdict; + } +} + const read = async (protocol: ObjectStackProtocolImplementation, packageId?: string, name = VIEW) => (await protocol.getMetaItem({ type: 'view', name, ...(packageId ? { packageId } : {}) })) as any; @@ -233,7 +254,7 @@ describe('[#28238] a dependent package\'s row under a name the base ships is not it('the card\'s three steps answer base / dependent / base, and the layered read and the base\'s list agree', async () => { const { protocol, rowsOf } = await boot(); await installDependent(protocol); - await saveIntoDependent(protocol, VIEW, DEPENDENT_COLUMNS); + await saveIntoDependentBeforeTheGate(protocol, DEPENDENT_COLUMNS); expect(rowsOf(VIEW).map((r) => [r.package_id, r.state])).toEqual([[DEPENDENT, 'active']]); expect(columnsOf((await read(protocol, BASE)).item)).toEqual(BASE_COLUMNS); @@ -254,18 +275,24 @@ describe('[#28238] a dependent package\'s row under a name the base ships is not expect(listed.items.filter((i: any) => i.name === VIEW).map((i: any) => columnsOf(i))).toEqual([BASE_COLUMNS]); }); - it('the write address does not move: a save naming no package still writes the dependent package\'s row', async () => { + it('the write address does not move: a save naming no package still addresses the dependent package\'s row, which the override gate refuses', async () => { const { protocol, rowsOf } = await boot(); await installDependent(protocol); - await saveIntoDependent(protocol, VIEW, DEPENDENT_COLUMNS); + await saveIntoDependentBeforeTheGate(protocol, DEPENDENT_COLUMNS); expect((await (protocol as any).packagelessAddress('view', VIEW)).packageId).toBe(DEPENDENT); - await protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(['title']) } as any); + // [#28410, ADR-0146 D2, ruling A on #28390] The save naming no package + // resolves to the dependent package's row — the address does not move + // — and that write is a dependent's same-named item, which the + // override write gate refuses (rename): the refusal names the + // dependent package, and the stored row is untouched. + await expect(protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(['title']) } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, message: expect.stringContaining(`'${DEPENDENT}'`) }); const rows = rowsOf(VIEW).filter((r) => r.state === 'active'); expect(rows.map((r) => r.package_id)).toEqual([DEPENDENT]); - expect(columnsOf(JSON.parse(String(rows[0].metadata)))).toEqual(['title']); - expect(columnsOf((await read(protocol, DEPENDENT)).item)).toEqual(['title']); + expect(columnsOf(JSON.parse(String(rows[0].metadata)))).toEqual(DEPENDENT_COLUMNS); + expect(columnsOf((await read(protocol, DEPENDENT)).item)).toEqual(DEPENDENT_COLUMNS); expect(columnsOf((await read(protocol)).item)).toEqual(BASE_COLUMNS); }); @@ -297,7 +324,7 @@ describe('[#28238] a dependent package\'s row under a name the base ships is not it('beside the dependent package\'s row, the package-unbound row is what the read naming no package serves', async () => { const { engine, protocol } = await boot(); await installDependent(protocol); - await saveIntoDependent(protocol, VIEW, DEPENDENT_COLUMNS); + await saveIntoDependentBeforeTheGate(protocol, DEPENDENT_COLUMNS); await storeUnboundRow(engine, ['title', 'priority']); expect(columnsOf((await read(protocol)).item)).toEqual(['title', 'priority']); diff --git a/packages/rest/src/public-form-withdrawal-effective-read.test.ts b/packages/rest/src/public-form-withdrawal-effective-read.test.ts index de826018a05..a35712940c0 100644 --- a/packages/rest/src/public-form-withdrawal-effective-read.test.ts +++ b/packages/rest/src/public-form-withdrawal-effective-read.test.ts @@ -20,7 +20,8 @@ * draft save and the per-item publish. * * - a dependent package's row of the base's form view that withdraws the - * form closes it on both doors, although the effective list no longer + * form (a row stored before #28410's write gate, which now refuses that + * save) closes it on both doors, although the effective list no longer * holds that row (the pin the card names; its ablation reads only the * effective list and re-opens the form); * - CONTROL: with no withdrawing row the base's form is served; @@ -155,7 +156,23 @@ async function boot(baseAllowsAnonymous = true) { await protocol.saveMetaItem({ type: 'view', name: VIEW, item, mode: 'draft', packageId }); await protocol.publishMetaItem({ type: 'view', name: VIEW, packageId }); }; - return { engine, protocol, get, post, install, saveInto }; + // [#28410, ADR-0146 D2, ruling A on #28390] A dependent package's row under + // a name the base ships is refused at the save door now (rename is its one + // fix), so the withdrawing row this file pins is one stored BEFORE that gate: + // the door's refusal is asserted first, then the same body goes through the + // same draft save and per-item publish with the gate's verdict held at + // `not_concerned` for that one write, the row a pre-gate save left. + const saveIntoBeforeTheGate = async (packageId: string, item: Record) => { + await expect(protocol.saveMetaItem({ type: 'view', name: VIEW, item, mode: 'draft', packageId })) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + protocol.overrideWriteVerdict = () => ({ verdict: 'not_concerned' }); + try { + await saveInto(packageId, item); + } finally { + delete protocol.overrideWriteVerdict; + } + }; + return { engine, protocol, get, post, install, saveInto, saveIntoBeforeTheGate }; } type Booted = Awaited>; @@ -193,9 +210,9 @@ describe('[#28409] the form doors judge withdrawal against the effective read, n }); it('a dependent package\'s row that withdraws the base\'s form closes it on both doors, though the effective list no longer holds it', async () => { - const { protocol, get, post, install, saveInto } = await booted(); + const { protocol, get, post, install, saveIntoBeforeTheGate } = await booted(); await install(DEPENDENT); - await saveInto(DEPENDENT, formView(false)); + await saveIntoBeforeTheGate(DEPENDENT, formView(false)); // The premise: the effective list serves the base's (public) document // alone under the name, and the withdrawing row is the dependent's own diff --git a/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts b/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts index 4d10ef3097f..6849e1ae7a0 100644 --- a/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts +++ b/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts @@ -113,10 +113,15 @@ describe('PUT / DELETE /api/v1/meta/flow/:name on a packaged flow — the refusa expect(r.status).toBe(403); expect(r.code).toBe('NOT_OVERRIDABLE'); // [#23577] What the door knows and where the names are listed — no - // create advice, no source-artifact advice. + // create advice, no source-artifact advice. [#28410, ADR-0146 D2] On a + // type a declared override can replace, the sentence then names the + // author's two fixes: declare the override from the base's paired + // `.custom`, or rename (the card's "declare or rename" sealed sentence). expect(r.message).toBe( `'page/${PACKAGED_PAGE}' is provided by a managed package and is sealed: it cannot be written here. ` - + 'GET /api/v1/meta/page lists the names in use.', + + 'GET /api/v1/meta/page lists the names in use. ' + + "To change it, declare an override from 'com.example.pkg.custom' " + + "(overrides: { package: 'com.example.pkg', basedOn }), or rename the item.", ); }); }); diff --git a/packages/runtime/src/domains/packages-customizations.test.ts b/packages/runtime/src/domains/packages-customizations.test.ts index 76327ef0108..a934c62b94e 100644 --- a/packages/runtime/src/domains/packages-customizations.test.ts +++ b/packages/runtime/src/domains/packages-customizations.test.ts @@ -19,7 +19,9 @@ * - AGREEMENT: every listed override is the item the layered read of the * base's name reports as `overlay`, the `.custom` row that declares it; * - CONTROLS: a declaration from a non-pair package is not listed; an - * undeclared pair row is not an override; another package's extension and + * undeclared pair row is not an override (both rows are refused at the + * save door since #28410, so the test asserts that refusal and lays them + * down below the gate, as rows from before it); another package's extension and * ledger rows are not the base's; a base with no pair answers empty lists * and 200 and no pair is created; a `.custom` id is refused naming its base; * an unknown id answers 404; a caller without the read capability is @@ -271,6 +273,34 @@ async function saveView( await protocol.publishMetaItem({ type: 'view', name, packageId } as any); } +/** + * [#28410, ADR-0146 D2, ruling A on #28390] A row the override write gate now + * refuses at the save door, laid down as it was before the gate: the door's + * refusal (`403 NOT_OVERRIDABLE`, its sentence matching `refused`) is asserted + * first, then the same draft save and publish run with the gate answering + * `not_concerned` for them alone. Such rows exist from before the gate, so + * the read below still has to treat them right; what is pinned is the read. + */ +async function saveViewBeforeTheGate( + protocol: ObjectStackProtocolImplementation, + packageId: string, + name: string, + columns: readonly string[], + refused: RegExp, + extra: Record = {}, +): Promise { + const body = { ...listBody(name, columns), ...extra }; + await expect(protocol.saveMetaItem({ type: 'view', name, item: body, mode: 'draft', packageId } as any)) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, message: expect.stringMatching(refused) }); + const gated = protocol as any; + gated.overrideWriteVerdict = () => ({ verdict: 'not_concerned' }); + try { + await saveView(protocol, packageId, name, columns, extra); + } finally { + delete gated.overrideWriteVerdict; + } +} + /** One `extend` contribution, as `registerApp` registers a manifest's `objectExtensions` entry. */ const extend = (engine: ObjectQL, packageId: string, object: string, body: Record) => engine.registry.registerObject({ name: object, ...body } as any, packageId, undefined, 'extend'); @@ -335,7 +365,7 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an it('a declaration from a package that is not the base\'s pair is not an override of the base', async () => { const { dispatcher, protocol } = await boot(); await install(protocol, DEPENDENT); - await saveView(protocol, DEPENDENT, VIEW, ['title', 'status'], { overrides: { ...DECLARATION } }); + await saveViewBeforeTheGate(protocol, DEPENDENT, VIEW, ['title', 'status'], /carries `overrides`/, { overrides: { ...DECLARATION } }); const data = (await customizations(dispatcher, BASE)).response?.body?.data; expect(data.overrides).toEqual([]); expect(data.counts.overrides).toBe(0); @@ -344,7 +374,7 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an it('a pair row without the declaration is the pair\'s own item, not an override', async () => { const { dispatcher, protocol } = await boot(); await install(protocol, PAIR); - await saveView(protocol, PAIR, VIEW, ['title']); + await saveViewBeforeTheGate(protocol, PAIR, VIEW, ['title'], /declares no override/); const data = (await customizations(dispatcher, BASE)).response?.body?.data; expect(data.overrides).toEqual([]); expect(data.counts.overrides).toBe(0); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 7f0bd03fc9e..55e45caaa86 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1232,6 +1232,13 @@ export const ERROR_CODE_LEDGER = { // the code `@objectstack/cloud-connection` already registers — one // condition, one vocabulary; provenance, not identity (see above). 'ENVIRONMENT_NOT_FOUND', + // [#28410, ADR-0146 D2] `os validate` refuses an item the override write + // gate refuses (`utils/override-declarations.ts`, step 3a-iii): each row on + // `errors` (`--json`, exit 1) carries this code, the one the publish doors + // answer the same item with. `door: 'none'`: a CLI exit, no HTTP boundary. + // Registered under `@objectstack/metadata-protocol`, so this row is + // provenance, not identity. + 'NOT_OVERRIDABLE', // [#20367 ruling B] `os validate` / `os build` refuse a config whose default // export no stack producer built (`hasStackProvenance`, `@objectstack/spec`) // — right after load, before any other judgement, through each command's @@ -1280,6 +1287,12 @@ export const ERROR_CODE_LEDGER = { 'MARKETPLACE_STORAGE_FAILED', // install-record read/write failed 'MARKETPLACE_UNAVAILABLE', 'NOTHING_TO_PURGE', // package declares no seed datasets + // [#28410, ADR-0146 D2] install-local refused a managed install whose items + // the override write gate refuses (step 2d, `marketplace-install-local-plugin.ts`), + // 403, by the plugin's OWN Hono route, before any write; `details.refusals` + // names each item. The code the publish doors answer the same item with, + // registered under `@objectstack/metadata-protocol`: provenance, not identity. + 'NOT_OVERRIDABLE', 'PLUGIN_INSTALL_FAILED', 'PLUGIN_MANIFEST_INVALID', 'PLUGIN_REGISTER_FAILED', diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3d9581c8aff..b86fa02b285 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1356,6 +1356,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.packageless-layered-read-refusal.test.ts", "verb": "delete",