diff --git a/.changeset/29396-withdrawal-carry-into-pair.md b/.changeset/29396-withdrawal-carry-into-pair.md
new file mode 100644
index 00000000000..2385bdce165
--- /dev/null
+++ b/.changeset/29396-withdrawal-carry-into-pair.md
@@ -0,0 +1,17 @@
+---
+'@objectstack/cli': patch
+---
+
+fix(cli): `os migrate organization-ownership --apply` carries an organization's public-form withdrawal onto a read-only package's view into that package's `.custom` pair, declared, instead of stopping
+
+Clause-②: no
+Metadata-protocol: none
+
+The ceremony carries every organization-layer public-form withdrawal onto the environment (`metadata-promotion`). On a view a read-only package ships, that save named the package itself, which the metadata door now refuses (ADR-0146 D1, D11: the read-only base takes no customization), so the ceremony stopped with a blocker and the operator had to save the withdrawal into the pair by hand.
+
+- **Read-only package.** The carry now ensures the package's `.custom` pair through the ensure door (the in-process `POST /packages/:id/ensure-customization`), then saves the withdrawn view into the pair declaring `overrides: { package: , basedOn: }`. Every read of the base's name serves that override, so the form stays withdrawn. It counts in `withdrawalsCarried`, and the progress line names the pair. The manual step in the changeset that closed the write naming the base (save the withdrawal into the pair, then re-run) is no longer needed when the pair can be ensured.
+- **Writable package.** Its view is still saved in place, as before.
+- **Pair not available.** When the pair cannot be ensured, or the door refuses the declared save into it, the carry makes the save naming the package as before: where the door admits it, that save carries the withdrawal; where it refuses, the step blocks as before, and the blocker now also names the pair's refusal.
+- A re-run carries nothing more: the served view already has the form withdrawn.
+
+No door, no accepted request shape and no error code changes.
diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts
index 7abd5b5e3a9..72241687b2b 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.ts
@@ -41,6 +41,7 @@ import {
import {
applyOrganizationOwnership,
checkpointFromDetails,
+ protocolCustomizationPairs,
MetadataChoiceRefusal,
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
judgeDeclaredSharingRules,
@@ -751,7 +752,7 @@ export default class OrganizationOwnershipCeremony extends Command {
// The dialect, once, before the first statement: an unresolved one is
// refused here, naming the driver, and never read as SQLite.
const dialect = ceremonyDialectOf(stack.driver);
- const { normalizeRows } = await import('@objectstack/metadata-protocol');
+ const { isWritablePackage, normalizeRows } = await import('@objectstack/metadata-protocol');
// [#23816] A boot that skipped schema sync measured no table absent, so
// the ledger's presence is read from the catalog instead.
const ledgerAbsent = stack.schemaSync === 'skipped'
@@ -774,6 +775,7 @@ export default class OrganizationOwnershipCeremony extends Command {
getMetaItems?: (request: { type: string }) => Promise<{ items?: unknown[] } | unknown[]>;
getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>;
getMetaItem?: (request: { type: string; name: string; packageId?: string }) => Promise;
+ ensureCustomizationPackage?: (request: { basePackageId: string }) => Promise<{ packageId: string }>;
} | undefined;
const catalog = createSecurityCatalogReader({
registry: engine.registry as never,
@@ -926,6 +928,21 @@ export default class OrganizationOwnershipCeremony extends Command {
};
},
},
+ // [ADR-0146 D1, D3] A withdrawal onto a read-only package's view goes
+ // into its `.custom` pair: the save door's own writability predicate,
+ // and the ensure door, in process.
+ pairs: protocolCustomizationPairs({
+ ensureCustomizationPackage: async (request) => {
+ if (typeof protocol?.ensureCustomizationPackage !== 'function') {
+ throw new Error('no metadata protocol is composed on this stack');
+ }
+ return protocol.ensureCustomizationPackage(request);
+ },
+ isWritablePackage: (packageId) => isWritablePackage(engine, packageId),
+ baseVersion: (packageId) =>
+ (engine.registry as { getPackage?(id: string): { manifest?: { version?: unknown } } | undefined } | undefined)
+ ?.getPackage?.(packageId)?.manifest?.version,
+ }),
views: {
// The env-wide view list, exactly as the anonymous form doors read it.
list: async () => {
diff --git a/packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts
new file mode 100644
index 00000000000..5ef2c2ad3bd
--- /dev/null
+++ b/packages/cli/src/commands/migrate/organization-ownership.withdrawal-pair.integration.test.ts
@@ -0,0 +1,244 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os migrate organization-ownership --apply`, the form-withdrawal carry onto
+ * a view a READ-ONLY package ships (ADR-0146 D1, D2, D3).
+ *
+ * The read-only base itself takes no customization: the metadata door refuses
+ * a save naming it where its `.custom` pair can take the write (the named-base
+ * close). So the carry ensures the pair through the ensure door and saves the
+ * withdrawal into it, declaring `overrides: { package: , basedOn }`.
+ *
+ * ## The fixture
+ *
+ * A project whose config is a booted code package (read-only: its manifest is
+ * registered at boot) shipping an object and a public form view, over a
+ * database a served boot of this tree left behind, shaped like v17:
+ * `sys_metadata` carries `organization_id`, and an organization's row of the
+ * form view withdraws it (`sharing.enabled: false`).
+ *
+ * ## What each case measures
+ *
+ * The real command, in process through oclif (plan, then `--apply`): the step
+ * carries the withdrawal into the pair, the pair is in `sys_packages`, its row
+ * declares the override at the base's version, and the env-wide view list the
+ * anonymous doors judge by serves the form withdrawn. A second `--apply` over
+ * the same database finds the step checkpointed and writes nothing. (A re-run
+ * of the carry itself, with no checkpoint, is pinned in
+ * `organization-ownership-apply.test.ts`.)
+ */
+
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import Database from 'better-sqlite3';
+import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { randomBytes } from 'node:crypto';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core';
+import { isExitSignal } from '../../utils/format.js';
+import { bootSchemaStack } from '../../utils/schema-migrate.js';
+import MigrateOrganizationOwnership from './organization-ownership.js';
+
+// [#10126] Pay the first load of what the command reaches through dynamic
+// `import()`s at MODULE LOAD, outside the clocked case.
+import '@objectstack/runtime';
+import '@objectstack/objectql';
+import '@objectstack/metadata-protocol';
+import { PackageServicePlugin } from '@objectstack/service-package';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const CLI_ROOT = resolve(HERE, '..', '..', '..');
+const CASE_TIMEOUT_MS = 240_000;
+
+const OVERRIDING_ENV = [
+ 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH',
+ 'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'OS_MULTI_ORG_ENABLED',
+] as const;
+
+const BASE = 'com.example.wdpair';
+const PAIR = `${BASE}.custom`;
+const VERSION = '1.4.0';
+const VIEW = 'wdp_lead.intake';
+const SLUG = 'wdp-intake';
+const DATA = { provider: 'object', object: 'wdp_lead' };
+const FORM = {
+ type: 'simple',
+ data: DATA,
+ sections: [{ name: 'main', label: 'Main', columns: 1, fields: [{ field: 'name' }] }],
+ sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` },
+};
+const CONFIG = {
+ manifest: { id: BASE, name: 'Withdrawal Pair', version: VERSION, type: 'app', namespace: 'wdp' },
+ objects: [{ name: 'wdp_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }],
+ views: [{ list: { label: 'Leads', type: 'grid', data: DATA, columns: [{ field: 'name' }] }, formViews: { intake: FORM } }],
+};
+/** The organization's 17.x row of the form view: the served body, the form withdrawn. */
+const WITHDRAWN = {
+ name: VIEW,
+ object: 'wdp_lead',
+ viewKind: 'form',
+ label: 'Intake',
+ config: { ...FORM, sharing: { ...FORM.sharing, enabled: false } },
+};
+
+let dir: string;
+let projectDir: string;
+let templateDb: string;
+const savedEnv: Record = {};
+const savedCwd = process.cwd();
+
+/** The real command over `dbFile`: the plan run first (its file is the reviewed plan), then `--apply`. */
+async function applyOver(dbFile: string, tag: string): Promise<{ report: any; exitCode: number }> {
+ const savedExit = process.exitCode;
+ const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
+ const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
+ if (cb) cb();
+ return true;
+ }) as typeof process.stdout.write;
+ const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
+ vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ process.chdir(projectDir);
+ let exitCode: number | undefined;
+ try {
+ const planFile = join(projectDir, `plan-${tag}.json`);
+ await MigrateOrganizationOwnership.run(['--out', planFile, '--database-url', `file:${dbFile}`, '--json'], { root: CLI_ROOT });
+ stdout.mockClear();
+ try {
+ await MigrateOrganizationOwnership.run(
+ [
+ '--apply', '--backup-taken', '--plan-file', planFile,
+ '--out', join(projectDir, `apply-${tag}.json`), '--database-url', `file:${dbFile}`, '--json',
+ ],
+ { root: CLI_ROOT },
+ );
+ } catch (error) {
+ if (!isExitSignal(error)) throw error;
+ exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit;
+ }
+ const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim());
+ expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined();
+ return { report: payload.report, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 };
+ } finally {
+ process.chdir(savedCwd);
+ process.exitCode = savedExit;
+ vi.restoreAllMocks();
+ }
+}
+
+const promotion = (report: any): { status: string; summary: Record; blockers: string[] } =>
+ report.steps.find((s: { step: string }) => s.step === 'metadata-promotion');
+
+/** The stored `view` rows of the form, `package/state`, with the declaration each body carries. */
+function storedForm(dbFile: string): Array<{ pkg: string; enabled: unknown; overrides: unknown }> {
+ const db = new Database(dbFile, { readonly: true });
+ try {
+ return (db.prepare(`SELECT package_id, metadata FROM sys_metadata WHERE type = 'view' AND name = ? ORDER BY package_id`)
+ .all(VIEW) as Array<{ package_id: string | null; metadata: string }>)
+ .map((row) => {
+ const body = JSON.parse(row.metadata) as { config?: { sharing?: { enabled?: unknown } }; overrides?: unknown };
+ return { pkg: row.package_id ?? '-', enabled: body.config?.sharing?.enabled, overrides: body.overrides };
+ });
+ } finally {
+ db.close();
+ }
+}
+
+/** On a fresh boot of `dbFile`: the base's served form, and the views the anonymous doors serve the slug from. */
+async function served(dbFile: string): Promise<{ base: unknown; open: string[] }> {
+ process.chdir(projectDir);
+ const stack = await bootSchemaStack({
+ jsonOutput: false, databaseUrl: `file:${dbFile}`, projectRoot: projectDir, composeHostStack: true,
+ // The package registry `os serve` composes, so the pair installs from `sys_packages` as it does there.
+ extraPlugins: [new PackageServicePlugin()],
+ });
+ try {
+ const protocol = stack.kernel.getService('protocol') as any;
+ const base = (await protocol.getMetaItem({ type: 'view', name: VIEW, packageId: BASE }))?.item;
+ const listed = await protocol.getMetaItems({ type: 'view' });
+ const list = ((Array.isArray(listed) ? listed : listed?.items) ?? []) as Array>;
+ const open = list
+ .filter((v) => anonymousFormIntakeCandidates(v).some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(list, v, c)))
+ .map((v) => `${String(v.name)}|${String(v._packageId ?? '')}`);
+ return { base: (base as { config?: { sharing?: unknown } } | undefined)?.config?.sharing, open };
+ } finally {
+ await stack.shutdown();
+ process.chdir(savedCwd);
+ }
+}
+
+beforeAll(async () => {
+ for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key];
+ for (const key of OVERRIDING_ENV) delete process.env[key];
+ process.env.NODE_ENV = 'production';
+ process.env.OS_SECRET_KEY = randomBytes(32).toString('hex');
+
+ dir = mkdtempSync(join(tmpdir(), 'os-ceremony-withdrawal-pair-'));
+ projectDir = join(dir, 'project');
+ mkdirSync(projectDir, { recursive: true });
+ writeFileSync(join(projectDir, 'objectstack.config.mjs'), `export default ${JSON.stringify(CONFIG, null, 2)};\n`);
+
+ // A served boot of this tree provisions the tables; then the v17 shape.
+ templateDb = join(dir, 'template.db');
+ process.chdir(projectDir);
+ try {
+ const seed = await bootSchemaStack({
+ jsonOutput: false, databaseUrl: `file:${templateDb}`, projectRoot: projectDir, composeHostStack: true,
+ extraPlugins: [new PackageServicePlugin()],
+ });
+ await seed.shutdown();
+ } finally {
+ process.chdir(savedCwd);
+ }
+ const db = new Database(templateDb);
+ try {
+ db.exec(`ALTER TABLE "sys_metadata" ADD COLUMN "organization_id" varchar(255)`);
+ const now = new Date().toISOString();
+ db.prepare(
+ `INSERT INTO sys_metadata (id, organization_id, type, name, package_id, state, version, created_at, updated_at, metadata) ` +
+ `VALUES ('md_org', 'org_x', 'view', ?, NULL, 'active', 1, ?, ?, ?)`,
+ ).run(VIEW, now, now, JSON.stringify(WITHDRAWN));
+ } finally {
+ db.close();
+ }
+}, CASE_TIMEOUT_MS);
+
+afterAll(() => {
+ process.chdir(savedCwd);
+ for (const [key, value] of Object.entries(savedEnv)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('organization-ownership --apply: a withdrawal onto a read-only package\'s view goes into its .custom pair', () => {
+ it('⭐ carried into the pair, declared as the override of the base; the served form is withdrawn; a re-run carries nothing', async () => {
+ const dbFile = join(dir, 'case.db');
+ copyFileSync(templateDb, dbFile);
+ expect((await served(dbFile)).open, 'PRECONDITION: the shipped form is served open').toEqual([`${VIEW}|${BASE}`]);
+
+ const { report } = await applyOver(dbFile, 'first');
+ const step = promotion(report);
+ expect(step.blockers.filter((b) => b.includes(VIEW))).toEqual([]);
+ expect(step.summary.withdrawalsCarried).toBe(1);
+ expect(storedForm(dbFile).filter((r) => r.pkg === PAIR)).toEqual([
+ { pkg: PAIR, enabled: false, overrides: { package: BASE, basedOn: VERSION } },
+ ]);
+ // ⛔ Nothing bound to the read-only base itself.
+ expect(storedForm(dbFile).filter((r) => r.pkg === BASE)).toEqual([]);
+
+ const after = await served(dbFile);
+ expect(after.base).toMatchObject({ enabled: false });
+ expect(after.open).toEqual([]);
+
+ // A re-run finds the step checkpointed and writes nothing more.
+ const rows = storedForm(dbFile);
+ const again = await applyOver(dbFile, 'again');
+ expect(promotion(again.report).status).toBe('checkpointed');
+ expect(storedForm(dbFile)).toEqual(rows);
+ }, CASE_TIMEOUT_MS);
+});
diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts
index 120d3e19bde..04a8a087c63 100644
--- a/packages/cli/src/utils/organization-ownership-apply.test.ts
+++ b/packages/cli/src/utils/organization-ownership-apply.test.ts
@@ -39,6 +39,7 @@ import {
type CeremonyCheckpoint,
type CeremonyLedgerPort,
type ColumnDropPort,
+ type CustomizationPairPort,
type DefaultOrganizationPort,
type MetadataDoorPort,
type MetadataLockPort,
@@ -465,6 +466,8 @@ const apply = (
securityPlugin?: SchemaMigrationComposition['securityPlugin'];
defaultOrganization?: DefaultOrganizationPort;
declaredSharingRules?: readonly string[];
+ pairs?: CustomizationPairPort;
+ onProgress?: (message: string) => void;
} = {},
): Promise => {
c.boot();
@@ -487,6 +490,8 @@ const apply = (
...(opts.metadataChoices ? { metadataChoices: opts.metadataChoices } : {}),
...(opts.securityPlugin ? { securityPlugin: opts.securityPlugin } : {}),
...(opts.defaultOrganization ? { defaultOrganization: opts.defaultOrganization } : {}),
+ ...(opts.pairs ? { pairs: opts.pairs } : {}),
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
...(opts.declaredSharingRules
? { declaredSharingRules: { rules: async () => opts.declaredSharingRules!.map((name) => ({ name, refused: null })) } }
: {}),
@@ -1271,6 +1276,118 @@ describe('ADR-0131 D10 --apply — organization customizations and form withdraw
expect(step(report).status).toBe('blocked');
expect(step(report).blockers.join('\n')).toMatch(/lead-intake of the view crm_lead\.intake \(package pkg_a\).*NOT_OVERRIDABLE.*sharing\.enabled false/);
});
+
+ describe('[ADR-0146 D1, D2, D3] a read-only package\'s view takes the withdrawal in its .custom pair', () => {
+ /** The pair doors: `readOnly` names the read-only packages; the ensure door answers `.custom` at version 2.1.0. */
+ const pairsOf = (readOnly: readonly string[], refuseEnsure?: string): CustomizationPairPort & { ensured: string[] } => {
+ const ensured: string[] = [];
+ return {
+ ensured,
+ readOnly: async (packageId) => readOnly.includes(packageId),
+ ensure: async (base) => {
+ if (refuseEnsure) throw new Error(refuseEnsure);
+ ensured.push(base);
+ return { packageId: `${base}.custom`, basedOn: '2.1.0' };
+ },
+ };
+ };
+ /**
+ * The env-wide list as the reads serve it (ADR-0146 D8 item 2): a pair's
+ * save declaring the override of its base is served at the base's slot,
+ * over the base's own document.
+ */
+ const servedOverPairs = (c: ReturnType): ServedViewsPort => {
+ const plain = servedViews(c);
+ return {
+ async list() {
+ const slots = new Map((await plain.list()).map((v) => [`${String(v.name)}|${String(v._packageId ?? '')}`, v]));
+ for (const [key, v] of [...slots]) {
+ const declared = (v.overrides as { package?: unknown } | undefined)?.package;
+ if (typeof declared !== 'string' || v._packageId !== `${declared}.custom`) continue;
+ slots.delete(key);
+ slots.set(`${String(v.name)}|${declared}`, { ...v, _packageId: declared });
+ }
+ return [...slots.values()];
+ },
+ };
+ };
+ const saved = (c: ReturnType) =>
+ c.viewSaves.map((v) => [v.name, v.packageId, (v.body.config as any).sharing.enabled, v.body.overrides]);
+
+ it('⭐ carried into the pair, declared at the base\'s version; the doors serve it withdrawn; a re-run carries nothing', async () => {
+ metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy')));
+ const c = catalog();
+ const views = servedOverPairs(c);
+ const pairs = pairsOf(['pkg_a']);
+ const progress: string[] = [];
+ expect(await doorsServe(views, 'lead-intake')).toEqual(['crm_lead.intake|pkg_a']);
+
+ const report = await apply(c, ledger(), { views, pairs, onProgress: (m) => progress.push(m) });
+
+ expect(step(report)).toMatchObject({ status: 'done', summary: { withdrawalsCarried: 1 } });
+ expect(pairs.ensured).toEqual(['pkg_a']);
+ // ⛔ Nothing saved naming the read-only base itself.
+ expect(saved(c)).toEqual([['crm_lead.intake', 'pkg_a.custom', false, { package: 'pkg_a', basedOn: '2.1.0' }]]);
+ expect(progress.filter((m) => m.includes('pkg_a.custom'))).toHaveLength(1);
+ expect(await doorsServe(views, 'lead-intake')).toEqual([]);
+
+ const again = await apply(c, ledger(), { views, pairs });
+ expect(step(again).summary.withdrawalsCarried).toBe(0);
+ expect(c.viewSaves).toHaveLength(1);
+ });
+
+ it('a writable package\'s view is saved in place, as before; no pair is ensured', async () => {
+ metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy')));
+ const c = catalog();
+ const pairs = pairsOf([]);
+ const report = await apply(c, ledger(), { views: servedViews(c), pairs });
+ expect(step(report)).toMatchObject({ status: 'done', summary: { withdrawalsCarried: 1 } });
+ expect(saved(c)).toEqual([['crm_lead.intake', 'pkg_a', false, undefined]]);
+ expect(pairs.ensured).toEqual([]);
+ });
+
+ it('a pair that cannot be ensured keeps the blocker, the pair\'s refusal named beside the door\'s; nothing is written', async () => {
+ metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false)));
+ const c = catalog();
+ const refusing: ReturnType = {
+ ...c,
+ async save(type, name, body, target) {
+ if (type === 'view') throw new Error('NOT_OVERRIDABLE: the read-only base takes no customization');
+ return c.save(type, name, body, target);
+ },
+ };
+ const report = await apply(refusing, ledger(), { views: servedViews(c), pairs: pairsOf(['pkg_a'], 'NAMESPACE_CONFLICT: the pair namespace is held') });
+ expect(step(report).status).toBe('blocked');
+ const blocker = step(report).blockers.join('\n');
+ expect(blocker).toMatch(/lead-intake of the view crm_lead\.intake \(package pkg_a\).*NOT_OVERRIDABLE/);
+ expect(blocker).toMatch(/customization package of pkg_a could not be ensured: NAMESPACE_CONFLICT/);
+ expect(c.viewSaves).toEqual([]);
+ });
+
+ it('a door that refuses the declared save into the pair keeps the blocker too, naming that refusal', async () => {
+ metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false)));
+ const c = catalog();
+ const refusing: ReturnType = {
+ ...c,
+ async save(type, name, body, target) {
+ if (type === 'view') throw new Error(`NOT_OVERRIDABLE: refused in ${String(target?.packageId)}`);
+ return c.save(type, name, body, target);
+ },
+ };
+ const report = await apply(refusing, ledger(), { views: servedViews(c), pairs: pairsOf(['pkg_a']) });
+ expect(step(report).status).toBe('blocked');
+ expect(step(report).blockers.join('\n')).toMatch(/refused in pkg_a\. Nor could its customization package take it: the metadata door refused the save into pkg_a\.custom: NOT_OVERRIDABLE: refused in pkg_a\.custom/);
+ expect(c.viewSaves).toEqual([]);
+ });
+
+ it('control: where the pair cannot take it and the door admits the save naming the package, that save carries it, as before', async () => {
+ metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false)));
+ const c = catalog();
+ const report = await apply(c, ledger(), { views: servedViews(c), pairs: pairsOf(['pkg_a'], 'the base declares no namespace') });
+ expect(step(report)).toMatchObject({ status: 'done', summary: { withdrawalsCarried: 1 } });
+ expect(saved(c)).toEqual([['crm_lead.intake', 'pkg_a', false, undefined]]);
+ });
+ });
});
describe('ADR-0131 D10 --apply — a customization conflict resolved by --metadata-choice (ruling 6163114098: Q1 B, Q6 A)', () => {
diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts
index 7c99b779d80..b629ad6b6c2 100644
--- a/packages/cli/src/utils/organization-ownership-apply.ts
+++ b/packages/cli/src/utils/organization-ownership-apply.ts
@@ -77,7 +77,10 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
* organization's withdrawal wins), by writing it onto each active
* environment `view` of that name that still serves the form open,
* through the metadata door; the same predicate the anonymous doors
- * judge by (`anonymousFormIntakeWithdrawnIn`) decides. Then, under
+ * judge by (`anonymousFormIntakeWithdrawnIn`) decides. A view a
+ * read-only package ships takes it in that package's `.custom` pair,
+ * declared as the override of the base (ADR-0146 D1, D2, D3:
+ * {@link CustomizationPairPort}). Then, under
* `single`, each organization-scoped `sys_metadata` row of the five
* presentational types whose overlay key no other row holds becomes an
* environment row in place, its history with it (its `organization_id`
@@ -268,6 +271,49 @@ export interface MetadataDoorPort {
): Promise;
}
+/**
+ * [ADR-0146 D1, D2, D3] The customization pair of a read-only package, for the
+ * one write the ceremony makes onto an item such a package ships: the
+ * withdrawal carry. The read-only base itself takes no customization, so the
+ * carry saves into `.custom`, declaring `overrides: { package: ,
+ * basedOn }` ({@link carryFormWithdrawals}).
+ */
+export interface CustomizationPairPort {
+ /** Whether the installed package is read-only: the server's own writability verdict, negated (ADR-0070 D2). */
+ readOnly(packageId: string): Promise;
+ /**
+ * Ensure the base's pair through the ensure door (`POST
+ * /packages/:id/ensure-customization`, in process). Throws the door's
+ * refusal. Answers the pair's id and the base's version now: the `basedOn`
+ * the override declares.
+ */
+ ensure(basePackageId: string): Promise<{ packageId: string; basedOn: string }>;
+}
+
+/**
+ * The command's {@link CustomizationPairPort}, over the booted stack: the
+ * writability predicate the save door judges by, and the protocol's ensure
+ * door. `basedOn` is the installed base's `manifest.version`, the value the
+ * package-less overlay ceremony records too.
+ */
+export function protocolCustomizationPairs(deps: {
+ ensureCustomizationPackage(request: { basePackageId: string }): Promise<{ packageId: string }>;
+ isWritablePackage(packageId: string): boolean;
+ baseVersion(packageId: string): unknown;
+}): CustomizationPairPort {
+ return {
+ readOnly: async (packageId) => !deps.isWritablePackage(packageId),
+ ensure: async (basePackageId) => {
+ const { packageId } = await deps.ensureCustomizationPackage({ basePackageId });
+ const basedOn = deps.baseVersion(basePackageId);
+ if (typeof basedOn !== 'string' || basedOn === '') {
+ throw new Error(`the installed package '${basePackageId}' declares no version, so no basedOn can be declared`);
+ }
+ return { packageId, basedOn };
+ },
+ };
+}
+
/** The env-wide `view` list the anonymous form doors read (every package's item of a name). */
export interface ServedViewsPort {
list(): Promise>>>;
@@ -641,6 +687,12 @@ export interface ApplyOptions {
notNull?: NotNullPort;
/** The served views, for carrying form withdrawals. */
views: ServedViewsPort;
+ /**
+ * The customization pair a withdrawal onto a read-only package's view is
+ * carried into. Absent, the carry saves naming the served package, and the
+ * door's refusal of that save blocks.
+ */
+ pairs?: CustomizationPairPort;
/** The door's lock verdict, for the one write the ceremony makes in place. */
lock: MetadataLockPort;
columnDrops: ColumnDropPort;
@@ -1546,6 +1598,8 @@ const servedPackageOf = (item: Readonly>): string | null
return typeof pkg === 'string' && pkg !== '' && pkg !== METADATA_OBJECT ? pkg : null;
};
+const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
+
/** A served item as a body the door accepts: its runtime annotations (`_`-prefixed keys) dropped. */
const authoredBodyOf = (item: Readonly>): Record =>
JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))))) as Record;
@@ -1564,6 +1618,16 @@ const authoredBodyOf = (item: Readonly>): Record, blockers: string[]): Promise {
const t = METADATA_OBJECT;
@@ -1599,6 +1663,19 @@ async function carryFormWithdrawals(run: Run, rows: readonly MetadataKeyRow[], s
for (const candidate of closing) {
candidate.form.sharing = { ...(candidate.form.sharing as Record), enabled: false };
}
+ let pairRefusal: string | undefined;
+ if (packageId !== null && run.opts.pairs) {
+ const outcome = await carryIntoPair(run, run.opts.pairs, name, packageId, body);
+ if (outcome.carried) {
+ summary.withdrawalsCarried += closing.length;
+ run.opts.onProgress?.(
+ `carried ${closing.length} public-form withdrawal(s) onto the view ${name} through the customization package ` +
+ `${outcome.pair}, declared as the override of ${packageId} at ${outcome.basedOn}`,
+ );
+ continue;
+ }
+ pairRefusal = outcome.refusal;
+ }
try {
await run.opts.door.save('view', name, body, { packageId });
summary.withdrawalsCarried += closing.length;
@@ -1607,13 +1684,54 @@ async function carryFormWithdrawals(run: Run, rows: readonly MetadataKeyRow[], s
blockers.push(
`An organization withdrew the public form(s) ${closing.map((c) => c.slug).join(', ')} of the view ${name}` +
`${packageId ? ` (package ${packageId})` : ''}, and the metadata door refused to carry that withdrawal onto ` +
- `the environment: ${error instanceof Error ? error.message : String(error)}. Withdraw the form there ` +
- '(sharing.enabled false), then re-run. An upgrade never reopens a withdrawn form (ruling record 6020151485).',
+ `the environment: ${messageOf(error)}.${pairRefusal ? ` Nor could its customization package take it: ${pairRefusal}.` : ''} ` +
+ 'Withdraw the form there (sharing.enabled false), then re-run. An upgrade never reopens a withdrawn form ' +
+ '(ruling record 6020151485).',
);
}
}
}
+/**
+ * [ADR-0146 D1, D2, D3] Carry a withdrawal onto `base`'s view `name` into the
+ * base's `.custom` pair, when `base` is read-only: the pair ensured through
+ * the ensure door, then `body` saved into it through the metadata door,
+ * declaring `overrides: { package: base, basedOn }` (a body that already
+ * declares the base, the pair's own override as served, keeps its
+ * declaration). `carried: false` with no refusal: `base` is writable, and its
+ * view is saved in place. With one: the pair could not take it, in the
+ * door's or the ensure door's own words.
+ */
+async function carryIntoPair(
+ run: Run,
+ pairs: CustomizationPairPort,
+ name: string,
+ base: string,
+ body: Record,
+): Promise<{ carried: true; pair: string; basedOn: string } | { carried: false; refusal?: string }> {
+ try {
+ if (!(await pairs.readOnly(base))) return { carried: false };
+ } catch (error) {
+ return { carried: false, refusal: `whether the package ${base} is read-only could not be read: ${messageOf(error)}` };
+ }
+ let pair: { packageId: string; basedOn: string };
+ try {
+ pair = await pairs.ensure(base);
+ } catch (error) {
+ return { carried: false, refusal: `the customization package of ${base} could not be ensured: ${messageOf(error)}` };
+ }
+ const own = body.overrides as { package?: unknown; basedOn?: unknown } | undefined;
+ const overrides = own && typeof own === 'object' && own.package === base && typeof own.basedOn === 'string'
+ ? { package: base, basedOn: own.basedOn }
+ : { package: base, basedOn: pair.basedOn };
+ try {
+ await run.opts.door.save('view', name, { ...body, overrides }, { packageId: pair.packageId });
+ } catch (error) {
+ return { carried: false, refusal: `the metadata door refused the save into ${pair.packageId}: ${messageOf(error)}` };
+ }
+ return { carried: true, pair: pair.packageId, basedOn: overrides.basedOn };
+}
+
/** One conflict the operator chose a row for. */
interface MetadataResolution {
conflict: MetadataConflict;