Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/28410-override-write-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/cli': minor
'@objectstack/cloud-connection': minor
'@objectstack/spec': patch
---

**BREAKING** feat(metadata-protocol)!: the override write gate — only a base's paired `<base>.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 `<base>.custom` package or the sealed-write sentence; the `content/docs/references/**` pages that show `overrides` are generated from the spec and do not move.

<!-- adr-0087: not-required (no-migration-prescription) The narrowing is a refusal at the write doors (publish, `os validate`, the marketplace managed install) of item NAMES and of an `overrides` declaration outside the base's paired package: no metadata key, accepted value, export or error code is removed or renamed, and no stored shape changes. The remedy is a rename of an item, or a declaration added in the base's paired `.custom` package, both authoring choices a person makes; there is no mechanical conversion between two metadata versions for `objectstack migrate meta` to apply, and the precondition count on #28410 found no same-named item inside a dependency closure in hotcrm or the repository's examples. The other categories are closed on facts: all three packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); the one `packages/spec/**` file touched is the error-code provenance ledger (`api/error-code-ledger.zod.ts`), two rows naming new emitters of the already-registered `NOT_OVERRIDABLE`, so no schema, type or runtime interface moves (not type-surface-only, not runtime-interface-only); and no export is retired (not runtime-export-retired). -->

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 `<base>.custom` package declares an override (`overrides: { package: '<base>', 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 `<base>.custom` the sentence says to declare it or rename it; in any other dependent, to rename it.
- **`overrides` outside the pair.** A declaration on a package that is not the base's paired `.custom`.
- **A declaration the closure cannot back.** `overrides.package` outside the declaring package's dependency closure (the sentence says to declare the dependency), or a base that ships no item of that type and name.
- **A type that takes no override.** An excluded type (permission, position, capability, the code-only types…), pointing to creating a new item under a new name; an `object`, pointing to `objectExtensions`.
- **A channel not yet open.** A declared `flow` ("flow customization is not yet available") or `action`.

**Where.** The save door (`saveMetaItem`, so `PUT /api/v1/meta/:type/:name` and every authoring path through it), the promotion (`publishMetaItem` and `publishPackageDrafts`), `os validate` (each package of the artifact against its closure inside the artifact; exit 1), and the marketplace managed install (`POST /api/v1/marketplace/install-local`, before any write). The undeclared-same-name refusal is asked last at the publish doors, so every refusal those doors already answered keeps its code and status.

**Admitted.** The base's paired `.custom` declaring an override of an item its base ships, on an open type: the save and the promotion write it as the pair's own row, and the managed install registers it.

**The install answer lists upgrade collisions.** An install of a base whose new version ships a name an installed dependent already holds, undeclared, answers `200` with `overrideCollisions` (`type`, `name`, `packageId`, `rule`, `message`), one row per held item; the key is omitted when there is none. The install is not refused: the dependent's item is the one to rename, or in `.custom`, to declare.

**The sealed sentence.** A write over a sealed item of a type a declared override can replace now names the writer's route in place of none. Written from the base's paired `.custom`, or naming no package, it ends "To change it, declare an override from '<base>.custom' (overrides: { package: '<base>', basedOn }), or rename the item."; written from any other package, "To change it, rename the item: only '<base>.custom' may declare an override of '<base>'."

**A malformed `overrides` is not a declaration.** A value the spec's `OverrideDeclarationSchema` refuses (null, not an object, no or an empty `package`, no `basedOn`) is not judged by the gate; the save door's schema gate answers it (`422 INVALID_METADATA`), and `os validate` reports it at its schema step.

`@objectstack/metadata-protocol` exports the gate's rule, `judgeOverrideWrite`, and its item walk, `overrideWriteItemsOf`, so the three doors ask one judgement.

`@objectstack/spec` records the two new emitters of `NOT_OVERRIDABLE` in its error-code provenance ledger (`api/error-code-ledger.zod.ts`, under `@objectstack/cli` and `@objectstack/cloud-connection`). The code was already registered, so the union, casing and every schema are unchanged.
32 changes: 32 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s
// over the parsed stack the way the load path registers it. The verdict is
// `@objectstack/objectql`'s; see the module header.
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
// [ADR-0146 D2, #28410] The override write gate's author-time half: the publish
// door's own judge, walked over the artifact's packages.
import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js';
// A field `picklist` that names no picklist the stack declares — refused, or
// reported when the declaring package depends on packages outside the stack.
// Walked the way the load path registers, like the refusal above.
Expand Down Expand Up @@ -658,6 +661,35 @@ export default class Validate extends Command {
}
}

// 3a-iii. [ADR-0146 D2, #28410] The override write gate, at author time.
// Only the base's paired `<base>.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<string, unknown>);
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
Expand Down
134 changes: 134 additions & 0 deletions packages/cli/src/utils/override-declarations.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) =>
({ manifest: { ...manifest(id, dependencies), ...collections } });

const base = pkg(BASE, [], {
dashboards: [{ name: 'crm_board', label: 'Board' }],
pages: [{ name: 'crm_home', label: 'Home' }],
actions: [{ name: 'log_call', objectName: 'crm_account', label: 'Log' }],
});

const judged = (type: string, name: string, packageId: string, item: unknown, dependencies: string[], shippers: string[]) => {
const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers });
if (v.verdict !== 'refuse') throw new Error(`expected a refusal, got ${v.verdict}`);
return v.message;
};

describe('#28410 — findOverrideDeclarationRefusals walks the artifact under the publish door\'s judge', () => {
it('a partner\'s item bearing a name the base in its closure ships is refused, in the judge\'s words', () => {
const item = { name: 'crm_board', label: 'Mine' };
const rows = findOverrideDeclarationRefusals({
manifest: manifest('com.acme.bundle'),
packages: [base, pkg(PARTNER, [BASE], { dashboards: [item] })],
});
expect(rows).toEqual([{
path: 'packages[1].manifest.dashboards[0]',
code: 'NOT_OVERRIDABLE',
rule: 'undeclared_same_name',
packageId: PARTNER,
message: judged('dashboard', 'crm_board', PARTNER, item, [BASE], [BASE]),
}]);
});

it('the closure is transitive through the artifact\'s packages', () => {
const rows = findOverrideDeclarationRefusals({
manifest: manifest('com.acme.bundle'),
packages: [base, pkg('com.acme.mid', [BASE], {}), pkg(PARTNER, ['com.acme.mid'], { pages: [{ name: 'crm_home' }] })],
});
expect(rows.map((r) => [r.packageId, r.rule])).toEqual([[PARTNER, 'undeclared_same_name']]);
});

it('controls: a package outside the closure, an own name, and an action on another object are not refused', () => {
expect(findOverrideDeclarationRefusals({
manifest: manifest('com.acme.bundle'),
packages: [
base,
pkg('com.other', [], { dashboards: [{ name: 'crm_board' }] }),
pkg(PARTNER, [BASE], {
dashboards: [{ name: 'partner_board' }],
actions: [{ name: 'log_call', objectName: 'partner_lead', label: 'Log' }],
}),
],
})).toEqual([]);
});

it('the pair\'s declared override is admitted; its undeclared same name is told to declare or rename', () => {
const declared = { name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } };
const undeclared = { name: 'crm_home' };
const rows = findOverrideDeclarationRefusals({
manifest: manifest('com.acme.bundle'),
packages: [base, pkg(PAIR, [BASE], { dashboards: [declared], pages: [undeclared] })],
});
expect(rows).toEqual([{
path: 'packages[1].manifest.pages[0]',
code: 'NOT_OVERRIDABLE',
rule: 'undeclared_same_name',
packageId: PAIR,
message: judged('page', 'crm_home', PAIR, undeclared, [BASE], [BASE]),
}]);
expect(rows[0].message).toMatch(/Declare it/);
});

it('a one-package .custom stack whose base is installed elsewhere: declarations are judged, the shipped name is left to the door', () => {
const rows = findOverrideDeclarationRefusals({
manifest: manifest(PAIR, [BASE]),
dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }],
objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' } }],
flows: [{ name: 'crm_flow', overrides: { package: BASE, basedOn: '1.0.0' } }],
pages: [{ name: 'crm_home', overrides: { package: 'com.unlisted', basedOn: '1.0.0' } }],
});
expect(rows.map((r) => [r.path, r.rule])).toEqual([
['objects[0]', 'declared_object'],
['flows[0]', 'declared_type_not_yet_available'],
['pages[0]', 'declared_outside_pair'],
]);
});

it('a dependent carrying `overrides` is refused, and a declaration of an item the base does not ship is refused', () => {
const rows = findOverrideDeclarationRefusals({
manifest: manifest('com.acme.bundle'),
packages: [
base,
pkg(PARTNER, [BASE], { dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }] }),
pkg(PAIR, [BASE], { dashboards: [{ name: 'no_such_board', overrides: { package: BASE, basedOn: '1.0.0' } }] }),
],
});
expect(rows.map((r) => [r.packageId, r.rule])).toEqual([
[PARTNER, 'declared_outside_pair'],
[PAIR, 'declared_package_ships_no_such_item'],
]);
});

it('a pair that does not depend on its base is told to declare the dependency', () => {
const rows = findOverrideDeclarationRefusals({
manifest: manifest('com.acme.bundle'),
packages: [base, pkg(PAIR, [], { dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }] })],
});
expect(rows.map((r) => r.rule)).toEqual(['declared_package_outside_closure']);
expect(rows[0].message).toMatch(/Declare a dependency on 'com\.acme\.crm'/);
});
});
Loading
Loading