From b24adb3a2a25cd09fb8002ba1aacbe2e4397aeb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:20:49 +0000 Subject: [PATCH 01/12] test(metadata-protocol): red-first pins for the override write gate at publish Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .../protocol.packaged-base-refusal.test.ts | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index b0e3403b764..d6ee83122cd 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -446,3 +446,349 @@ 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' }, + }; + /** 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 }) { + if (table !== 'sys_metadata') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts?: { where?: Record }) { + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()).filter((r) => matches(r, opts?.where ?? {})); + }, + 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 }) { + 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 }) { + 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: 'com.other.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 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: UNRELATED, item: body('page', 'crm_home') })); + expect(shape(e)).toEqual(REFUSED); + expect(String(e.message)).toContain(`'${PAIR}'`); + expect(String(e.message)).toMatch(/or rename the item/); + expect(String(e.message).toLowerCase()).not.toContain('clone'); + expect(String(e.message).length).toBeLessThan(500); + }); + + // ── 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 () => { + const { protocol, stored, seedDraft } = gateProtocol(); + seedDraft('dashboard', 'crm_board', PARTNER, body('dashboard', 'crm_board')); + seedDraft('dashboard', 'partner_board', PARTNER, body('dashboard', 'partner_board')); + const e = await thrown(protocol.publishPackageDrafts({ packageId: PARTNER })); + expect(shape(e)).toEqual(REFUSED); + expect(stored().every((r) => r.state === 'draft')).toBe(true); + }); +}); From 7e8b3bf8d410a971d0adad764656d97a1a7cd8ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:29:08 +0000 Subject: [PATCH 02/12] =?UTF-8?q?feat(metadata-protocol):=20override=20wri?= =?UTF-8?q?te=20gate=20at=20the=20publish=20doors=20=E2=80=94=20only=20the?= =?UTF-8?q?=20base's=20paired=20.custom=20declares=20an=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .../src/protocol-packaged-base-refusal.ts | 265 +++++++++++++++++- .../protocol.packaged-base-refusal.test.ts | 37 ++- packages/metadata-protocol/src/protocol.ts | 71 ++++- 3 files changed, 361 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 4d649c5c862..c0978d19c5a 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -38,6 +38,13 @@ 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, +} from '@objectstack/spec/kernel'; +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -50,11 +57,14 @@ export interface PackagedBaseRefusalHost { isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; + lookupArtifactItem: ObjectStackProtocolImplementation['lookupArtifactItem']; + 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 +134,264 @@ 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 two fixes the author has — declare the override + // from the base's paired `.custom`, or rename — in place of the bare + // seal: the override write gate ({@link judgeOverrideWrite}) admits + // exactly that declaration, so the seal is no longer the whole story. + const err = new Error( + managedItemSealedSentence(request.type, request.name, 'save') + + sealedDeclareOrRenameTail( + request.type, + (this.lookupArtifactItem(request.type, request.name) as { _packageId?: unknown } | undefined)?._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: declare the override from the base's paired `.custom`, or + * rename. 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): string { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return ''; + const pair = customizationPackageId(basePackageId); + if (!pair.ok) return ''; + 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_flow_not_yet_available' + | 'declared_action_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 `overrides` value an item body carries, or `undefined` when it carries none. */ +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 = (item as { overrides?: unknown }).overrides; + if (declared === undefined) return { present: false, base: undefined }; + const base = declared !== null && typeof declared === 'object' + ? (declared as { package?: unknown }).package + : undefined; + return { present: true, base: typeof base === 'string' && base.length > 0 ? base : undefined }; +} + +/** + * 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").`); + } + if (type === 'flow') { + return refuse('declared_flow_not_yet_available', + `${item} carries \`overrides\`: flow customization is not yet available, so a declared flow ` + + 'override is refused (ADR-0146 D9). Remove `overrides` and rename the item.'); + } + if (!isOpenOverrideType(type)) { + return refuse('declared_action_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).'); +} + +/** + * 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) + .filter((id): id is string => typeof id === 'string' && dependencies.includes(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 d6ee83122cd..b68664ce554 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -276,7 +276,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/); }); @@ -481,6 +486,8 @@ describe('the override write gate — only the base\'s paired .custom declares a [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 }> = [ @@ -691,7 +698,7 @@ describe('the override write gate — only the base\'s paired .custom declares a 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: 'com.other.custom', + 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); @@ -784,11 +791,29 @@ describe('the override write gate — only the base\'s paired .custom declares a }); 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', 'crm_board', PARTNER, body('dashboard', 'crm_board')); seedDraft('dashboard', 'partner_board', PARTNER, body('dashboard', 'partner_board')); - const e = await thrown(protocol.publishPackageDrafts({ packageId: PARTNER })); - expect(shape(e)).toEqual(REFUSED); - expect(stored().every((r) => r.state === 'draft')).toBe(true); + 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.ts b/packages/metadata-protocol/src/protocol.ts index 76b8a9b906e..27e5e6cd3a0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -378,7 +378,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. @@ -16503,10 +16509,34 @@ export class ObjectStackProtocolImplementation implements const host: ThisParameterType = { isSealedManagedItem: (...a) => this.isSealedManagedItem(...a), isWritablePackage: (...a) => this.isWritablePackage(...a), + lookupArtifactItem: (...a) => this.lookupArtifactItem(...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 = { + 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 @@ -20513,6 +20543,19 @@ 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). + const overrideVerdict = this.overrideWriteVerdict(request); + if (overrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(overrideVerdict); + const declaredOverride = overrideVerdict.verdict === 'admit'; if (request.source === undefined && request.writeFace !== 'package-duplicate') { const authored = await this.tenantAuthoredWriteRefusal({ type: request.type, @@ -20689,7 +20732,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 @@ -21675,7 +21721,9 @@ export class ObjectStackProtocolImplementation implements // surfaced as a 409. 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 @@ -23239,6 +23287,21 @@ 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). + let declaredOverride = false; + if (draftForGate) { + const overrideVerdict = this.overrideWriteVerdict({ + type: singularType, name: request.name, packageId: promoteInto, item: draftForGate.body, + }); + if (overrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(overrideVerdict); + 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 @@ -23351,7 +23414,7 @@ export class ObjectStackProtocolImplementation implements : []; 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, From d1eab6623cbddc4aec99d4da86f62d7025d8239f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:42:41 +0000 Subject: [PATCH 03/12] fix(metadata-protocol): override gate judges read-only bases; undeclared same name refused last; export the judge The undeclared-same-name refusal throws just before the store at the save and promote doors, after every refusal those doors already answered, so no existing code or status changes. Only a read-only (installed) package is a base the gate guards. The sealed sentence's base id is read through packagedArtifactOwner. judgeOverrideWrite is exported for os validate and the marketplace install door. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- packages/metadata-protocol/src/index.ts | 4 +++ .../src/protocol-packaged-base-refusal.ts | 19 ++++++----- .../src/protocol.sealed-remedy-kind.test.ts | 21 ++++++++++-- packages/metadata-protocol/src/protocol.ts | 34 ++++++++++++++----- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 4d99ca5445d..ad5d0da8e9f 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, 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 c0978d19c5a..f8fb907092e 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -57,14 +57,14 @@ export interface PackagedBaseRefusalHost { isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; - lookupArtifactItem: ObjectStackProtocolImplementation['lookupArtifactItem']; + 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)) { @@ -142,10 +142,7 @@ export function refusePackagedBaseOverride( // exactly that declaration, so the seal is no longer the whole story. const err = new Error( managedItemSealedSentence(request.type, request.name, 'save') - + sealedDeclareOrRenameTail( - request.type, - (this.lookupArtifactItem(request.type, request.name) as { _packageId?: unknown } | undefined)?._packageId, - ), + + sealedDeclareOrRenameTail(request.type, this.packagedArtifactOwner(request)), ); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; @@ -366,7 +363,7 @@ export function overrideWriteRefusalError( * then asks {@link judgeOverrideWrite}. */ export function overrideWriteVerdict( - this: Pick, + this: Pick, request: OverrideWriteSubject, ): OverrideWriteVerdict { const packageId = typeof request.packageId === 'string' && request.packageId.length > 0 @@ -386,7 +383,13 @@ export function overrideWriteVerdict( : Array.from(new Set( this.shippedArtifactsOf(request.type, request.name) .map((a) => (a as { _packageId?: unknown } | null | undefined)?._packageId) - .filter((id): id is string => typeof id === 'string' && dependencies.includes(id)), + // 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 }); } 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 27e5e6cd3a0..453df3c7f11 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16509,7 +16509,7 @@ export class ObjectStackProtocolImplementation implements const host: ThisParameterType = { isSealedManagedItem: (...a) => this.isSealedManagedItem(...a), isWritablePackage: (...a) => this.isWritablePackage(...a), - lookupArtifactItem: (...a) => this.lookupArtifactItem(...a), + packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a), }; return refusePackagedBaseOverride.call(host, request); } @@ -16531,6 +16531,7 @@ export class ObjectStackProtocolImplementation implements */ private overrideWriteVerdict(request: OverrideWriteSubject): OverrideWriteVerdict { const host: ThisParameterType = { + isWritablePackage: (...a) => this.isWritablePackage(...a), resolveWritePackageScope: (...a) => this.resolveWritePackageScope(...a), shippedArtifactsOf: (...a) => this.shippedArtifactsOf(...a), }; @@ -20553,8 +20554,16 @@ export class ObjectStackProtocolImplementation implements // 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') throw overrideWriteRefusalError(overrideVerdict); + 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({ @@ -21719,6 +21728,9 @@ 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. + if (overrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(overrideVerdict); const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type; const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name); // [ADR-0146 D2] An admitted declared override writes the pair's own @@ -23294,14 +23306,18 @@ export class ObjectStackProtocolImplementation implements // `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). - let declaredOverride = false; - if (draftForGate) { - const overrideVerdict = this.overrideWriteVerdict({ + // As at the save door, a refused declaration throws here and an + // undeclared same name just before the store promotes (below), so no + // refusal this door already answered changes its code. + const overrideVerdict: OverrideWriteVerdict = draftForGate + ? this.overrideWriteVerdict({ type: singularType, name: request.name, packageId: promoteInto, item: draftForGate.body, - }); - if (overrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(overrideVerdict); - declaredOverride = overrideVerdict.verdict === 'admit'; + }) + : { verdict: 'not_concerned' }; + if (overrideVerdict.verdict === 'refuse' && overrideVerdict.rule !== 'undeclared_same_name') { + throw overrideWriteRefusalError(overrideVerdict); } + 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 @@ -23413,6 +23429,8 @@ export class ObjectStackProtocolImplementation implements }) : []; + // [ADR-0146 D2] The override write gate's undeclared-same-name refusal. + if (overrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(overrideVerdict); const artifactBacked = this.isArtifactBacked(singularType, request.name); const intent: 'override-artifact' | 'runtime-only' = artifactBacked && !declaredOverride ? 'override-artifact' : 'runtime-only'; From bbc2c3d586b05fcdf9065644b38a0615885b6fae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:51:22 +0000 Subject: [PATCH 04/12] feat(cli,cloud-connection): the override write gate at os validate and the marketplace managed install os validate walks each package of the artifact under the publish door's judge (judgeOverrideWrite) against its closure inside the artifact and exits 1 on a refusal. The managed install judges every delivered package's items before any write (403 NOT_OVERRIDABLE, one entry per item) and lists, on a base install, the names installed dependents already hold undeclared (overrideCollisions). The item walk (overrideWriteItemsOf) lives beside the judge in metadata-protocol so both doors name items one way. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .changeset/28410-override-write-gate.md | 33 +++ packages/cli/src/commands/validate.ts | 32 +++ .../src/utils/override-declarations.test.ts | 134 ++++++++++ .../cli/src/utils/override-declarations.ts | 131 ++++++++++ ...tplace-install-local-override-gate.test.ts | 217 ++++++++++++++++ .../src/marketplace-install-local-plugin.ts | 241 ++++++++++++++++++ packages/metadata-protocol/src/index.ts | 2 +- .../src/protocol-packaged-base-refusal.ts | 57 +++++ 8 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 .changeset/28410-override-write-gate.md create mode 100644 packages/cli/src/utils/override-declarations.test.ts create mode 100644 packages/cli/src/utils/override-declarations.ts create mode 100644 packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts diff --git a/.changeset/28410-override-write-gate.md b/.changeset/28410-override-write-gate.md new file mode 100644 index 00000000000..a491800968d --- /dev/null +++ b/.changeset/28410-override-write-gate.md @@ -0,0 +1,33 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/cli': minor +'@objectstack/cloud-connection': minor +--- + +**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 ends "To change it, declare an override from '.custom' (overrides: { package: '', basedOn }), or rename the item." in place of naming no route. + +`@objectstack/metadata-protocol` exports the gate's rule, `judgeOverrideWrite`, and its item walk, `overrideWriteItemsOf`, so the three doors ask one judgement. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 6133472813c..df94a5edbbe 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -73,6 +73,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. @@ -657,6 +660,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..00a147544a1 --- /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_flow_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..0c2621b257b --- /dev/null +++ b/packages/cli/src/utils/override-declarations.ts @@ -0,0 +1,131 @@ +// 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 { 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)); + const declared = isRecord(walked.item) && isRecord(walked.item.overrides) ? walked.item.overrides : undefined; + const base = typeof declared?.package === 'string' ? declared.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/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 86f1411152e..e7fcef0f40c 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -233,6 +233,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 @@ -511,6 +514,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); @@ -1924,6 +1992,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. @@ -2102,6 +2197,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 @@ -3020,6 +3119,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 ad5d0da8e9f..33df21a2292 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -34,7 +34,7 @@ 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, type OverrideWriteSubject, type OverrideWriteClosure, type OverrideWriteRule, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; +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 f8fb907092e..3e1bcc4cfb7 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -45,6 +45,7 @@ import { isCustomizationPairOf, } 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 — @@ -342,6 +343,62 @@ export function judgeOverrideWrite( + '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. From db944b8cdefc91f7e9627b1cfe0061433e70ec39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:02:17 +0000 Subject: [PATCH 05/12] test(cli): os validate refuses an override declaration the publish door refuses; record the build gap The flow and action not-yet-available refusals are one rule (declared_type_not_yet_available): they differ only by type. The parity ledger records findOverrideDeclarationRefusals as validate-only: the card named publish, os validate and install as the gate points. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .../src/utils/override-declarations.test.ts | 2 +- .../test/validate-build-gate-parity.test.ts | 12 ++- .../validate-override-declarations.test.ts | 99 +++++++++++++++++++ .../src/protocol-packaged-base-refusal.ts | 13 +-- 4 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 packages/cli/test/validate-override-declarations.test.ts diff --git a/packages/cli/src/utils/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts index 00a147544a1..f40b9f49338 100644 --- a/packages/cli/src/utils/override-declarations.test.ts +++ b/packages/cli/src/utils/override-declarations.test.ts @@ -103,7 +103,7 @@ describe('#28410 — findOverrideDeclarationRefusals walks the artifact under th }); expect(rows.map((r) => [r.path, r.rule])).toEqual([ ['objects[0]', 'declared_object'], - ['flows[0]', 'declared_flow_not_yet_available'], + ['flows[0]', 'declared_type_not_yet_available'], ['pages[0]', 'declared_outside_pair'], ]); }); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index e14ec11817a..39534c59d1a 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -220,7 +220,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/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 3e1bcc4cfb7..eee38384301 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -217,8 +217,7 @@ export type OverrideWriteRule = | 'declared_package_ships_no_such_item' | 'declared_type_not_overridable' | 'declared_object' - | 'declared_flow_not_yet_available' - | 'declared_action_not_yet_available'; + | 'declared_type_not_yet_available'; /** * The gate's verdict on one write: @@ -292,13 +291,11 @@ export function judgeOverrideWrite( `${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").`); } - if (type === 'flow') { - return refuse('declared_flow_not_yet_available', - `${item} carries \`overrides\`: flow customization is not yet available, so a declared flow ` - + 'override is refused (ADR-0146 D9). Remove `overrides` and rename the item.'); - } + // `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_action_not_yet_available', + 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.'); } From 4bc67b6c12d8b36752813f42a95265ae9c7aa7fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:14:17 +0000 Subject: [PATCH 06/12] refactor(metadata-protocol): the promotion throws the override gate's refusal once, last before the store The early declared-refusal throw at the promotion was redundant with the late one (an ablation of it read green): nothing between them answers a declared draft first. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 34ec3d12577..4eb81d4b9cb 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -23604,17 +23604,16 @@ export class ObjectStackProtocolImplementation implements // `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). - // As at the save door, a refused declaration throws here and an - // undeclared same name just before the store promotes (below), so no - // refusal this door already answered changes its code. + // 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' }; - if (overrideVerdict.verdict === 'refuse' && overrideVerdict.rule !== 'undeclared_same_name') { - throw overrideWriteRefusalError(overrideVerdict); - } 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 @@ -23727,7 +23726,7 @@ export class ObjectStackProtocolImplementation implements }) : []; - // [ADR-0146 D2] The override write gate's undeclared-same-name refusal. + // [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 && !declaredOverride From b90233124904660697c0cb92ee8e985f4e616fba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:43:30 +0000 Subject: [PATCH 07/12] test(metadata-protocol): the gate test's engine double routes through the dispatch asserts and holds the find bound Pinned in the engine-double ledger (check:engine-double-contract --write). Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .../src/protocol.packaged-base-refusal.test.ts | 9 +++++++-- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) 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 b68664ce554..89631805040 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'; @@ -537,12 +538,14 @@ describe('the override write gate — only the base\'s paired .custom declares a // 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 }) { + async find(table: string, opts?: { where?: Record; limit?: number }) { if (table !== 'sys_metadata') return []; - return Array.from(rows.values()).filter((r) => matches(r, opts?.where ?? {})); + 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' }; @@ -552,6 +555,7 @@ describe('the override write gate — only the base\'s paired .custom declares a 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) }; @@ -560,6 +564,7 @@ describe('the override write gate — only the base\'s paired .custom declares a 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); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 08ab86eca9c..652c4fe26b1 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-save-address.test.ts", "verb": "delete", From 9ead6858148f2efd23e8aa8da6a32c0075f160d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:06:15 +0000 Subject: [PATCH 08/12] fix(spec): record os validate and the marketplace install as emitters of NOT_OVERRIDABLE The error-code provenance guard reds on a registered code stamped by a package with no ledger row; the override write gate stamps NOT_OVERRIDABLE from @objectstack/cli and @objectstack/cloud-connection. Provenance only. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .changeset/28410-override-write-gate.md | 3 +++ packages/spec/src/api/error-code-ledger.zod.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/.changeset/28410-override-write-gate.md b/.changeset/28410-override-write-gate.md index a491800968d..6b0267da32b 100644 --- a/.changeset/28410-override-write-gate.md +++ b/.changeset/28410-override-write-gate.md @@ -2,6 +2,7 @@ '@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) @@ -31,3 +32,5 @@ A dependent package used to be able to ship, publish or install an item under th **The sealed sentence.** A write over a sealed item of a type a declared override can replace now ends "To change it, declare an override from '.custom' (overrides: { package: '', basedOn }), or rename the item." in place of naming no route. `@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/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index a3c4600130b..96ba8d2770a 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', From 96f6f99e8c0fb909b632f9d85ccb7ccef667f5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:21:52 +0000 Subject: [PATCH 09/12] fix(metadata-protocol): seed the read pins below the gate; writer-aware sealed advice; malformed overrides fall to the schema gate - The #28409 / #28238 read tests in objectql and rest seeded dependent and undeclared same-named rows through the save door, which ruling A on #28390 now refuses. Each now asserts that refusal, then seeds the row below the gate; the read assertions are unchanged. The package-less save onto a dependent's same-named row is refused on purpose and pinned so. - The sealed write's advice reads the writer: the base's paired .custom, or a write naming no package, is told to declare or rename; any other package is told to rename, since only the pair may declare. - An `overrides` value OverrideDeclarationSchema refuses is not judged as a declaration: the save door's schema gate answers it (422 INVALID_METADATA) in place of 403 declared_outside_pair. os validate's walker reads a declared base the same way. - The rest sealed-sentence pin and the changeset are updated. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .changeset/28410-override-write-gate.md | 6 ++- .../cli/src/utils/override-declarations.ts | 7 ++- .../src/protocol-packaged-base-refusal.ts | 51 ++++++++++++------ .../protocol.packaged-base-refusal.test.ts | 53 +++++++++++++++++-- ...col-custom-override-effective-read.test.ts | 38 +++++++++++-- ...packageless-read-dependent-package.test.ts | 45 ++++++++++++---- ...lic-form-withdrawal-effective-read.test.ts | 25 +++++++-- .../rest-meta-packaged-flow-refusal.test.ts | 9 +++- 8 files changed, 191 insertions(+), 43 deletions(-) diff --git a/.changeset/28410-override-write-gate.md b/.changeset/28410-override-write-gate.md index 6b0267da32b..9c95ba99fea 100644 --- a/.changeset/28410-override-write-gate.md +++ b/.changeset/28410-override-write-gate.md @@ -11,7 +11,7 @@ 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. @@ -29,7 +29,9 @@ A dependent package used to be able to ship, publish or install an item under th **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 ends "To change it, declare an override from '.custom' (overrides: { package: '', basedOn }), or rename the item." in place of naming no route. +**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. diff --git a/packages/cli/src/utils/override-declarations.ts b/packages/cli/src/utils/override-declarations.ts index 0c2621b257b..953aa2a68f4 100644 --- a/packages/cli/src/utils/override-declarations.ts +++ b/packages/cli/src/utils/override-declarations.ts @@ -37,6 +37,7 @@ */ import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem } from '@objectstack/metadata-protocol'; +import { OverrideDeclarationSchema } from '@objectstack/spec/kernel'; import { artifactPackages } from './artifact-packages.js'; @@ -112,8 +113,10 @@ export function findOverrideDeclarationRefusals(parsed: Record) for (const walked of pkg.items) { const key = `${walked.type}\u0000${walked.identity}`; const shippers = dependencies.filter((d) => shipped.get(d)?.has(key)); - const declared = isRecord(walked.item) && isRecord(walked.item.overrides) ? walked.item.overrides : undefined; - const base = typeof declared?.package === 'string' ? declared.package : undefined; + // 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)) { diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index eee38384301..91e0905ad94 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -43,6 +43,7 @@ import { customizationPackageId, getOverrideTypeClassification, isCustomizationPairOf, + OverrideDeclarationSchema, } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec'; @@ -137,13 +138,15 @@ export function refusePackagedBaseOverride( // the write meant to do: the door cannot tell a create from an edit. // // [ADR-0146 D2] On a type a declared override can replace, the - // sentence names the two fixes the author has — declare the override - // from the base's paired `.custom`, or rename — in place of the bare - // seal: the override write gate ({@link judgeOverrideWrite}) admits - // exactly that declaration, so the seal is no longer the whole story. + // 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)), + + sealedDeclareOrRenameTail(request.type, this.packagedArtifactOwner(request), request.packageId), ); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; @@ -153,16 +156,27 @@ export function refusePackagedBaseOverride( /** * [ADR-0146 D2] The sealed write's second sentence on a type whose override - * channel is open: declare the override from the base's paired `.custom`, or - * rename. Empty for a type with no open channel — `flow` and `action` wait for + * 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): string { +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.`; } @@ -235,15 +249,22 @@ export type OverrideWriteVerdict = const NOT_CONCERNED: OverrideWriteVerdict = { verdict: 'not_concerned' }; -/** The `overrides` value an item body carries, or `undefined` when it carries none. */ +/** + * 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 400 `VALIDATION_ERROR`, 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 = (item as { overrides?: unknown }).overrides; - if (declared === undefined) return { present: false, base: undefined }; - const base = declared !== null && typeof declared === 'object' - ? (declared as { package?: unknown }).package - : undefined; - return { present: true, base: typeof base === 'string' && base.length > 0 ? 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 }; } /** 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 89631805040..afc83180017 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -759,16 +759,61 @@ describe('the override write gate — only the base\'s paired .custom declares a // ── the sealed write's sentence ───────────────────────────────────────── - it('the sealed write of an overridable type says declare or rename, not clone; code and status stay', async () => { + 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: UNRELATED, item: body('page', 'crm_home') })); + 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)).toContain(`'${PAIR}'`); - expect(String(e.message)).toMatch(/or rename the item/); + 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 () => { 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 d93e624dad0..e6cfb1adc14 100644 --- a/packages/objectql/src/protocol-custom-override-effective-read.test.ts +++ b/packages/objectql/src/protocol-custom-override-effective-read.test.ts @@ -239,6 +239,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', { @@ -296,7 +324,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); @@ -310,7 +338,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); @@ -323,7 +351,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]]); @@ -336,7 +364,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]); @@ -382,7 +410,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 fd536fda16a..9e7e4a72787 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; @@ -146,7 +147,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>; @@ -184,9 +201,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.", ); }); }); From 15969ace16afd719972b7dba4679484cc1878c5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:31:38 +0000 Subject: [PATCH 10/12] fix(metadata-protocol): judge a package-less save again for the package its address gives The save door judged the override write gate before a save naming no package resolved its address, so a package-less save landing in a dependent's same-named row was "not concerned" and wrote the very row the same save naming the dependent is refused. The last refusal before the store now re-asks the gate for the inherited package (the publish address, or the draft address's package). Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 4eb81d4b9cb..148accb785c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22028,7 +22028,30 @@ export class ObjectStackProtocolImplementation implements // 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. - if (overrideVerdict.verdict === 'refuse') throw overrideWriteRefusalError(overrideVerdict); + // + // 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); // [ADR-0146 D2] An admitted declared override writes the pair's own From cd852e2aa9c3c9c9645e64aea3fa7598eb8314a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:51:29 +0000 Subject: [PATCH 11/12] test(runtime): the customizations read test lays the gate-refused rows down below the gate #28595's GET /packages/:id/customizations test seeded a non-pair declaration and an undeclared pair row through the save door, which the override write gate refuses (ruling A on #28390). Each now asserts that refusal and its sentence, then lays the row down with the gate answering not_concerned for that save alone; the read assertions are unchanged. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .../domains/packages-customizations.test.ts | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) 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); From 4a25192b91f97cc0c96a3d713abcadc3763daf3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:29:56 +0000 Subject: [PATCH 12/12] docs(metadata-protocol): the malformed-declaration note names the door's 422 INVALID_METADATA The schema gate a malformed `overrides` falls through to answers 422 INVALID_METADATA at the protocol door, as the pin asserts; the docblock said 400 VALIDATION_ERROR. Comment only. Claude-Session: https://claude.ai/code/session_01FEkQ5oYcVD24TCK4yt5e1i Co-authored-by: Claude --- .../metadata-protocol/src/protocol-packaged-base-refusal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 91e0905ad94..27271b43e2f 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -256,9 +256,9 @@ const NOT_CONCERNED: OverrideWriteVerdict = { verdict: 'not_concerned' }; * 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 400 `VALIDATION_ERROR`, 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. + * 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 };