Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions .changeset/25206-walled-self-registration-grant-sole-membership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/cli': patch
---

feat(cli): the ownership ceremony gives a walled organization-less self-registration grant its user's sole membership's organization

Clause-②: no
Metadata-protocol: none

Before the self-registration grant waited for the registrant's first membership, a sign-up under `isolated` or `group` wrote the grant (`sys_user_permission_set`) with no organization. `os migrate organization-ownership` reported every such row as unattributable, so each one held certification until the operator assigned its organization by hand.

Under a wall the ceremony now derives the owner when the user's memberships (`sys_member`) all name one organization. That is where the runtime settles the grant today. The plan counts these rows under `sole-membership:user_id->sys_member`, and `--apply` writes them.

A grant stays reported, never guessed and never deleted, in three cases:

- its user is a member of several organizations, because which membership came first is not recorded;
- its user is a member of none;
- its user already holds the same permission set in that organization, because the grant's unique index would refuse the write.

Each reported row's reason names its case. The remedy is unchanged: assign the organization, or delete the row by hand, then re-run `--apply`. Under `single` nothing changes, and the Default Organization takes every organization-less grant.
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The ADR-0131 D10 `--apply` on walled organization-less self-registration
* grants: before the grant waited for the registrant's first membership (ruling
* record 6257510973 item 1, Q1 A), a walled sign-up wrote it with no
* organization. The ceremony gives such a grant its user's sole membership's
* organization — where the runtime settles the grant today — and reports the
* rest, never guessing (D10 fate 4, ruling C on the C8 decision).
*
* The fixture is the measured legacy shape: one NULL `member_default` grant per
* self-registrant (id, user and set only), beside a grant already stamped.
*
* - Walled (`isolated`, `group`): the one-membership user's grant takes that
* organization; the grants of a user in two organizations, of a user in
* none, and of a user whose set an administrator already granted in the
* sole organization are reported and hold certification; nothing is
* deleted.
* - `single`: the Default Organization takes every NULL grant, as before.
*/

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildOrganizationOwnershipPlan, inventoryDigest, type OrganizationOwnershipPlan } from './organization-ownership-plan.js';
import {
applyOrganizationOwnership,
reviewedConflictsOf,
type ApplyExecutor,
type CeremonyCheckpoint,
type CeremonyLedgerPort,
type ColumnDropPort,
type OrganizationOwnershipApplyReport,
} from './organization-ownership-apply.js';

let dir: string;
let db: Database.Database;
/** Every write statement the ceremony issued, in order. */
let writes: string[];

function executor(): ApplyExecutor {
return {
dialect: 'sqlite',
client: 'better-sqlite3',
async query(sql, params = []) {
return db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[];
},
async execute(sql, params = []) {
writes.push(sql);
db.prepare(sql).run(...(params as unknown[]));
},
};
}

const columnDrops: ColumnDropPort = {
async drop(columns) {
return columns.map(({ table, column }) => {
db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`);
return { table, column, outcome: 'dropped' as const };
});
},
};

function ledger(): CeremonyLedgerPort {
let stored: CeremonyCheckpoint | null = null;
return {
async read() {
return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null;
},
async write(checkpoint) {
stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint;
},
};
}

const database = (): string => join(dir, 'fixture.db');

const plan = (posture: string): Promise<OrganizationOwnershipPlan> =>
buildOrganizationOwnershipPlan({ reader: executor(), posture, database: database() });

/** Plan, then apply that reviewed plan, as the command does. */
async function apply(posture: string, l = ledger()): Promise<OrganizationOwnershipApplyReport> {
const reviewed = await plan(posture);
return applyOrganizationOwnership({
executor: executor(),
posture,
database: database(),
reviewedPlan: {
file: 'plan.json',
generatedAt: reviewed.generatedAt,
database: database(),
inventoryDigest: inventoryDigest(),
posture,
conflicts: reviewedConflictsOf(reviewed),
},
// The grants' sets are declared: the rewrite verifies each name resolves.
catalog: {
async resolve(type, name) {
return type === 'permission' && ['member_default', 'viewer_readonly'].includes(name) ? { definition: { name } } : undefined;
},
},
door: {
async save(type, name) {
throw new Error(`no environment save is expected here (${type} ${name})`);
},
},
templates: { async find() { return undefined; } },
views: { async list() { return []; } },
lock: { async verdict() { return { editable: true }; } },
columnDrops,
ledger: l,
now: () => new Date('2026-10-19T00:00:00Z'),
});
}

const grants = (): Record<string, unknown>[] =>
db.prepare(`SELECT id, organization_id FROM sys_user_permission_set ORDER BY id`).all() as Record<string, unknown>[];

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'os-d10-sole-membership-'));
db = new Database(database());
writes = [];
db.exec(`
CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
INSERT INTO sys_organization VALUES ('org_default', 'default'), ('org_a', 'acme'), ('org_b', 'beta');
CREATE TABLE sys_user (id TEXT PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO sys_user VALUES ('usr_one', 'One', 'one@example.com'), ('usr_two', 'Two', 'two@example.com'),
('usr_none', 'None', 'none@example.com'), ('usr_held', 'Held', 'held@example.com');
CREATE TABLE sys_member (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, role TEXT);
INSERT INTO sys_member VALUES
('mem_one', 'org_a', 'usr_one', 'member'),
('mem_two_a', 'org_a', 'usr_two', 'member'),
('mem_two_b', 'org_b', 'usr_two', 'member'),
('mem_held', 'org_a', 'usr_held', 'member');
CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, permission_set TEXT);
CREATE UNIQUE INDEX uniq_grant ON sys_user_permission_set (user_id, permission_set, COALESCE(organization_id, '__global__'));
INSERT INTO sys_user_permission_set VALUES
('ups_one', NULL, 'usr_one', 'member_default'),
('ups_two', NULL, 'usr_two', 'member_default'),
('ups_none', NULL, 'usr_none', 'member_default'),
('ups_held', NULL, 'usr_held', 'member_default'),
('ups_held_admin', 'org_a', 'usr_held', 'member_default'),
('ups_stamped', 'org_b', 'usr_two', 'viewer_readonly');
`);
});

afterEach(() => {
db.close();
rmSync(dir, { recursive: true, force: true });
});

describe.each(['isolated', 'group'])('walled (%s): a NULL self-registration grant takes its user\'s sole membership\'s organization', (posture) => {
it('the plan attributes the one-membership grant and reports the others, each with its reason', async () => {
const reviewed = await plan(posture);
const table = reviewed.tables.find((t) => t.object === 'sys_user_permission_set')!;
expect(table.fateCounts).toMatchObject({ rowsToAttribute: 1, unattributableRows: 3 });
expect(table.attribution).toEqual({ 'sole-membership:user_id->sys_member': 1 });
expect(table.unattributable).toEqual([
{ id: 'ups_held', reason: expect.stringMatching(/user usr_held's sole membership names org_a, where row ups_held_admin already holds this key$/) },
{ id: 'ups_none', reason: expect.stringMatching(/user usr_none is a member of no organization$/) },
{ id: 'ups_two', reason: expect.stringMatching(/user usr_two is a member of 2 organizations, so the grant's is not derivable$/) },
]);
});

it('⭐ --apply writes the one-membership grant\'s organization; the reported grants stay NULL, hold certification, and none is deleted', async () => {
const report = await apply(posture);
expect(report.complete).toBe(true);
expect(report.blocking).toBe(3);
const table = report.tables.find((t) => t.object === 'sys_user_permission_set')!;
expect(table).toMatchObject({ attributed: 1, remaining: 3, held: { rows: 3 } });
expect(grants()).toEqual([
{ id: 'ups_held', organization_id: null },
{ id: 'ups_held_admin', organization_id: 'org_a' },
{ id: 'ups_none', organization_id: null },
{ id: 'ups_one', organization_id: 'org_a' },
{ id: 'ups_stamped', organization_id: 'org_b' },
{ id: 'ups_two', organization_id: null },
]);
expect(writes.filter((w) => /^DELETE/i.test(w) && /sys_user_permission_set/.test(w))).toEqual([]);
});
});

describe('single: the Default Organization takes every NULL grant, as before', () => {
it('no grant is attributed through a membership', async () => {
const reviewed = await plan('single');
const table = reviewed.tables.find((t) => t.object === 'sys_user_permission_set')!;
expect(table.attribution).toEqual({ 'default-organization': 4 });
expect(table.unattributable).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci
* platform states. Compile-time: both directions of `extends` must hold, so a
* key added to either side without the other fails `tsc` on this file.
*/
type HostAuthoredKeys = Exclude<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external' | 'underivableDeparture'>;
type HostAuthoredKeys = Exclude<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external' | 'underivableDeparture' | 'soleMembership'>;
type SpecRowKeys = OrganizationOwnershipSupplementEntry extends infer Row ? (Row extends unknown ? keyof Row : never) : never;
const KEYS_AGREE: [HostAuthoredKeys] extends [SpecRowKeys] ? ([SpecRowKeys] extends [HostAuthoredKeys] ? true : false) : false = true;

Expand Down Expand Up @@ -270,7 +270,7 @@ describe('the inventory row and the spec\'s supplement row are one shape', () =>
// anchor, category and departure form) is declared in the spec: a host can
// author anything the platform's own rows say.
const refused: string[] = [];
for (const { census: _census, cloudCarried: _cloud, external: _external, underivableDeparture: _underivable, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
for (const { census: _census, cloudCarried: _cloud, external: _external, underivableDeparture: _underivable, soleMembership: _membership, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
const result = OrganizationOwnershipSupplementEntrySchema.safeParse(row);
if (!result.success) refused.push(`${row.object}: ${result.error.issues[0]?.message}`);
}
Expand Down
39 changes: 38 additions & 1 deletion packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ export interface InventoryEntry {
* host declares no deletion, so the spec's supplement row has no such key.
*/
underivableDeparture?: Omit<InventoryDeparture, 'as' | 'where'>;
/**
* Fate 3 only, platform-only, under a wall only ({@link SoleMembershipAnchor}):
* tried after the anchors, on the NULL rows they left. A host's objects have
* no membership semantics the platform can read, so the spec's supplement row
* has no such key.
*/
soleMembership?: SoleMembershipAnchor;
categories?: readonly InventoryCategory[];
/**
* Fate 4 only: the column is not the tenancy anchor, so it never receives
Expand All @@ -181,6 +188,26 @@ export interface InventoryEntry {
note?: string;
}

/**
* A NULL row about a user takes the organization of that user's sole
* membership: every stamped `sys_member` row of the user named by `userKey`
* names one organization. It is where the runtime settles a self-registration
* grant under a wall, on the first membership (ruling record 6257510973 item
* 1, Q1 A). A user in several organizations, or in none, leaves the row
* reported: which membership came first is not recorded, so the owner is never
* guessed (ADR-0131 D10 fate 4). So is a row whose key another row of the table
* already holds in that organization (`userKey` with any one of `keyColumns`):
* the grant's unique index would refuse the write, and the operator keeps one.
* Under `single` the Default Organization takes the row, as before.
*/
export interface SoleMembershipAnchor {
userKey: string;
keyColumns: readonly string[];
}

/** The object whose rows are a user's memberships, each naming its organization. */
export const MEMBERSHIP_OBJECT = 'sys_member';

// ── Citations, spelled once ───────────────────────────────────────────────────

const CENSUS_1 = 'census record 5479883784 (2026-08-31, at 00d8f6541b)';
Expand Down Expand Up @@ -649,7 +676,17 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
anchors: [parent('business_unit_id', 'sys_business_unit')],
},
{ object: 'sys_user_position', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS} (assignments are organization rows); the business unit carries the organization`, anchors: [parent('business_unit_id', 'sys_business_unit')] },
{ object: 'sys_user_permission_set', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS} (assignments are organization rows); ${D10_SINGLE}`, anchors: [] },
{
object: 'sys_user_permission_set',
census: P1,
fate: 'attribution',
citation:
`${D3_ORG_ROWS} (assignments are organization rows); under a wall, a self-registration grant written before ` +
'the grant waited for the first membership (ruling record 6257510973 item 1) takes the user\'s sole ' +
`membership's organization; ${D10_SINGLE}`,
anchors: [],
soleMembership: { userKey: 'user_id', keyColumns: ['permission_set', 'permission_set_id'] },
},
{
object: 'sys_audience_binding_suggestion',
census: P1,
Expand Down
Loading
Loading