Skip to content

Commit 1eb1afa

Browse files
feat(cli): the ownership ceremony attributes a walled organization-less self-registration grant to its user's sole membership (#25272)
Fixes #25206 Part of the 18.x upgrade line: item A3 of #24343, carried from 6260319465. Measurement report: the measurement comment on #25206. Claim: 6345859138. ## What changes Under a wall, a self-registration grant written before #24453 has no organization (`sys_user_permission_set`). Today the ceremony gives it nothing to read (inventory `anchors: []`), so every such row is unattributable and holds certification until the operator fixes it by hand. This PR adds a platform-only, walled-only inventory key on that row: `soleMembership: { userKey: 'user_id', keyColumns: ['permission_set', 'permission_set_id'] }`. The plan tries it after the anchors: - **The user's stamped `sys_member` rows all name one organization:** the grant takes that organization. It is counted as `sole-membership:user_id->sys_member`, and `--apply` writes it through the existing attribution step. That is the organization the runtime gives the grant today (ruling record 6257510973 item 1). - **Several organizations:** reported. The reason is "user X is a member of N organizations, so the grant's is not derivable". Which membership came first is not recorded, so the owner is never guessed (D10 fate 4). - **No membership:** reported. The reason is "user X is a member of no organization". - **The key is already held there:** reported. This is the case where an administrator already granted that set to the user in that organization. The NULL-safe unique index (`user_id`, `permission_set`, organization) would refuse the `UPDATE` and stop the apply, so the anchor leaves the row for the operator to resolve. - **`single`:** unchanged. The Default Organization takes every NULL grant, and the key is not read. Nothing is deleted. The remedy text, ruling C on unattributable rows, the apply, the spec and `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` are all unchanged. **Why a platform-only key and not a spec anchor kind:** a host's objects have no membership semantics the platform can read. `underivableDeparture` is the precedent. The host-supplement parity test excludes the key, as it excludes that one. Clause-②: no Metadata-protocol: none - **Size:** 360 changed lines (+356 / −4) over 5 files. - **Governed paths:** `check-governed-merges --branch` reports none (0 of 5 paths), so no Tier H/S. - **Review tier:** `TIER_DEFAULT`. - **#25208:** its files are untouched here. `organization-ownership-plan.ts` gains one function and a three-line change in `walkNullRows`. `organization-ownership-apply.test.ts` is not edited; the pins are in a new file. ## Pins (`organization-ownership-apply.sole-membership.test.ts`, 5 cases) - **`isolated` and `group`, plan:** 1 attributed via `sole-membership`; 3 reported, each with its reason (two organizations, none, key already held). - **`isolated` and `group`, apply:** the one-membership grant gets `org_a`. The other three stay NULL with `blocking` 3, and no `DELETE` touches the table. - **`single`:** `default-organization: 4`, and nothing is reported. **Ablation** (each restored from the commit, and `git status` clean afterwards): | ablation | result | |:--|:--| | the inventory row loses `soleMembership` | 4 of 5 red (both walled plans and both walled applies) | | the anchor also runs under `single` | 1 of 5 red (the `single` pin) | | no held-key check | 4 of 5 red. Both applies throw on the unique index (`Object.onAttribution … organization-ownership-apply.ts:917`) | ## Proof - **Measurement** (the comment on #25206): a copy of `fixture-18.0` with the walled pre-#24453 trait. The real binary ran plan then apply. - On `main`, `isolated` and `group` each report 4 unattributable rows, apply exit 1 and `blocking` 4. - On this branch: 1 attributed, 3 reported, `blocking` 3. - `single`: 4 rows go to the Default Organization on both. - Baselines without the trait: `blocking` 0. - **`node scripts/pm/dispatch-gates.mjs --commands`:** 68 derived, and 68 exit 0. Each exit code was captured before any pipe. - `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone. After `git fetch --unshallow` it exited 0. - The run was on the pre-merge head `f2c4fc26`. The `main` merge since then touches none of these paths. - **`check-governed-merges --branch`:** NOT governed; 360 lines. - **Upgrade harness** (`upgrade-18x-harness.dogfood.test.ts`, both fixture legs and the fresh store): 16/16 pass. This covers R1's fresh store, R2 (18.0 and 18.0.0) and R3 (the v17 trait). - **R1, fresh boot:** a production `os serve` of the showcase on an empty database under `single`, `isolated` and `group`. Each is ready in 10–11 s, and health answers 200. - **R4, a walled production `os serve`** (`NODE_ENV=production`, `OS_PLATFORM_OWNER_EMAIL`, `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, `OS_SECRET_KEY`), over the measured store after this branch's ceremony: - Uncertified, with 3 grants reported: refused by the ownership boot guard, naming the ceremony. - The operator then resolved the 3 by hand: gave `usr_two`'s grant `org_beta`, and deleted the other two. - Re-run plan and apply: `complete`, `blocking` 0. - `os serve` was then ready under both `isolated` and `group`: health 200, and sign-in 200 for the fixture's admin and member A. - ⚠ The showcase declares no `@objectstack/organizations`, and a walled `os serve` refuses without it. For R4 and R1 only, the dependency was added locally and the lockfile restored afterwards. Nothing of that is in the diff. - **`@objectstack/cli`:** after the `main` merge (`c50f9b36`), `pnpm typecheck` exits 0 and every `organization-ownership*` suite passes (289, plus 1 skipped). - **NOT MEASURED: the whole `@objectstack/cli` suite.** The local run (`--maxWorkers=2`) was stopped at its 60-minute limit with no failure printed. CI's Test Core shards run it on this PR. ## Acceptance notes - The population on a real deployment is not measurable here. It is one row per walled self-registrant admitted before `95246668d`. On the one walled deployment on file (cloud's control plane), that grant duplicates the `isDefault` baseline (cloud#3051), so attributing it changes no one's access. - The 8b-0f report's alternative was to delete the grant of a user with no membership. It is not taken here: that would be a deletion outside fate 2, so it needs its own ruling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CpKhP8iqtGJKZHZ5PhdNq2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bda53bb commit 1eb1afa

5 files changed

Lines changed: 356 additions & 4 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): the ownership ceremony gives a walled organization-less self-registration grant its user's sole membership's organization
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.
13+
14+
A grant stays reported, never guessed and never deleted, in three cases:
15+
16+
- its user is a member of several organizations, because which membership came first is not recorded;
17+
- its user is a member of none;
18+
- its user already holds the same permission set in that organization, because the grant's unique index would refuse the write.
19+
20+
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.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The ADR-0131 D10 `--apply` on walled organization-less self-registration
5+
* grants: before the grant waited for the registrant's first membership (ruling
6+
* record 6257510973 item 1, Q1 A), a walled sign-up wrote it with no
7+
* organization. The ceremony gives such a grant its user's sole membership's
8+
* organization — where the runtime settles the grant today — and reports the
9+
* rest, never guessing (D10 fate 4, ruling C on the C8 decision).
10+
*
11+
* The fixture is the measured legacy shape: one NULL `member_default` grant per
12+
* self-registrant (id, user and set only), beside a grant already stamped.
13+
*
14+
* - Walled (`isolated`, `group`): the one-membership user's grant takes that
15+
* organization; the grants of a user in two organizations, of a user in
16+
* none, and of a user whose set an administrator already granted in the
17+
* sole organization are reported and hold certification; nothing is
18+
* deleted.
19+
* - `single`: the Default Organization takes every NULL grant, as before.
20+
*/
21+
22+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23+
import Database from 'better-sqlite3';
24+
import { mkdtempSync, rmSync } from 'node:fs';
25+
import { tmpdir } from 'node:os';
26+
import { join } from 'node:path';
27+
import { buildOrganizationOwnershipPlan, inventoryDigest, type OrganizationOwnershipPlan } from './organization-ownership-plan.js';
28+
import {
29+
applyOrganizationOwnership,
30+
reviewedConflictsOf,
31+
type ApplyExecutor,
32+
type CeremonyCheckpoint,
33+
type CeremonyLedgerPort,
34+
type ColumnDropPort,
35+
type OrganizationOwnershipApplyReport,
36+
} from './organization-ownership-apply.js';
37+
38+
let dir: string;
39+
let db: Database.Database;
40+
/** Every write statement the ceremony issued, in order. */
41+
let writes: string[];
42+
43+
function executor(): ApplyExecutor {
44+
return {
45+
dialect: 'sqlite',
46+
client: 'better-sqlite3',
47+
async query(sql, params = []) {
48+
return db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[];
49+
},
50+
async execute(sql, params = []) {
51+
writes.push(sql);
52+
db.prepare(sql).run(...(params as unknown[]));
53+
},
54+
};
55+
}
56+
57+
const columnDrops: ColumnDropPort = {
58+
async drop(columns) {
59+
return columns.map(({ table, column }) => {
60+
db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`);
61+
return { table, column, outcome: 'dropped' as const };
62+
});
63+
},
64+
};
65+
66+
function ledger(): CeremonyLedgerPort {
67+
let stored: CeremonyCheckpoint | null = null;
68+
return {
69+
async read() {
70+
return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null;
71+
},
72+
async write(checkpoint) {
73+
stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint;
74+
},
75+
};
76+
}
77+
78+
const database = (): string => join(dir, 'fixture.db');
79+
80+
const plan = (posture: string): Promise<OrganizationOwnershipPlan> =>
81+
buildOrganizationOwnershipPlan({ reader: executor(), posture, database: database() });
82+
83+
/** Plan, then apply that reviewed plan, as the command does. */
84+
async function apply(posture: string, l = ledger()): Promise<OrganizationOwnershipApplyReport> {
85+
const reviewed = await plan(posture);
86+
return applyOrganizationOwnership({
87+
executor: executor(),
88+
posture,
89+
database: database(),
90+
reviewedPlan: {
91+
file: 'plan.json',
92+
generatedAt: reviewed.generatedAt,
93+
database: database(),
94+
inventoryDigest: inventoryDigest(),
95+
posture,
96+
conflicts: reviewedConflictsOf(reviewed),
97+
},
98+
// The grants' sets are declared: the rewrite verifies each name resolves.
99+
catalog: {
100+
async resolve(type, name) {
101+
return type === 'permission' && ['member_default', 'viewer_readonly'].includes(name) ? { definition: { name } } : undefined;
102+
},
103+
},
104+
door: {
105+
async save(type, name) {
106+
throw new Error(`no environment save is expected here (${type} ${name})`);
107+
},
108+
},
109+
templates: { async find() { return undefined; } },
110+
views: { async list() { return []; } },
111+
lock: { async verdict() { return { editable: true }; } },
112+
columnDrops,
113+
ledger: l,
114+
now: () => new Date('2026-10-19T00:00:00Z'),
115+
});
116+
}
117+
118+
const grants = (): Record<string, unknown>[] =>
119+
db.prepare(`SELECT id, organization_id FROM sys_user_permission_set ORDER BY id`).all() as Record<string, unknown>[];
120+
121+
beforeEach(() => {
122+
dir = mkdtempSync(join(tmpdir(), 'os-d10-sole-membership-'));
123+
db = new Database(database());
124+
writes = [];
125+
db.exec(`
126+
CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
127+
INSERT INTO sys_organization VALUES ('org_default', 'default'), ('org_a', 'acme'), ('org_b', 'beta');
128+
CREATE TABLE sys_user (id TEXT PRIMARY KEY, name TEXT, email TEXT);
129+
INSERT INTO sys_user VALUES ('usr_one', 'One', 'one@example.com'), ('usr_two', 'Two', 'two@example.com'),
130+
('usr_none', 'None', 'none@example.com'), ('usr_held', 'Held', 'held@example.com');
131+
CREATE TABLE sys_member (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, role TEXT);
132+
INSERT INTO sys_member VALUES
133+
('mem_one', 'org_a', 'usr_one', 'member'),
134+
('mem_two_a', 'org_a', 'usr_two', 'member'),
135+
('mem_two_b', 'org_b', 'usr_two', 'member'),
136+
('mem_held', 'org_a', 'usr_held', 'member');
137+
CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, permission_set TEXT);
138+
CREATE UNIQUE INDEX uniq_grant ON sys_user_permission_set (user_id, permission_set, COALESCE(organization_id, '__global__'));
139+
INSERT INTO sys_user_permission_set VALUES
140+
('ups_one', NULL, 'usr_one', 'member_default'),
141+
('ups_two', NULL, 'usr_two', 'member_default'),
142+
('ups_none', NULL, 'usr_none', 'member_default'),
143+
('ups_held', NULL, 'usr_held', 'member_default'),
144+
('ups_held_admin', 'org_a', 'usr_held', 'member_default'),
145+
('ups_stamped', 'org_b', 'usr_two', 'viewer_readonly');
146+
`);
147+
});
148+
149+
afterEach(() => {
150+
db.close();
151+
rmSync(dir, { recursive: true, force: true });
152+
});
153+
154+
describe.each(['isolated', 'group'])('walled (%s): a NULL self-registration grant takes its user\'s sole membership\'s organization', (posture) => {
155+
it('the plan attributes the one-membership grant and reports the others, each with its reason', async () => {
156+
const reviewed = await plan(posture);
157+
const table = reviewed.tables.find((t) => t.object === 'sys_user_permission_set')!;
158+
expect(table.fateCounts).toMatchObject({ rowsToAttribute: 1, unattributableRows: 3 });
159+
expect(table.attribution).toEqual({ 'sole-membership:user_id->sys_member': 1 });
160+
expect(table.unattributable).toEqual([
161+
{ id: 'ups_held', reason: expect.stringMatching(/user usr_held's sole membership names org_a, where row ups_held_admin already holds this key$/) },
162+
{ id: 'ups_none', reason: expect.stringMatching(/user usr_none is a member of no organization$/) },
163+
{ id: 'ups_two', reason: expect.stringMatching(/user usr_two is a member of 2 organizations, so the grant's is not derivable$/) },
164+
]);
165+
});
166+
167+
it('⭐ --apply writes the one-membership grant\'s organization; the reported grants stay NULL, hold certification, and none is deleted', async () => {
168+
const report = await apply(posture);
169+
expect(report.complete).toBe(true);
170+
expect(report.blocking).toBe(3);
171+
const table = report.tables.find((t) => t.object === 'sys_user_permission_set')!;
172+
expect(table).toMatchObject({ attributed: 1, remaining: 3, held: { rows: 3 } });
173+
expect(grants()).toEqual([
174+
{ id: 'ups_held', organization_id: null },
175+
{ id: 'ups_held_admin', organization_id: 'org_a' },
176+
{ id: 'ups_none', organization_id: null },
177+
{ id: 'ups_one', organization_id: 'org_a' },
178+
{ id: 'ups_stamped', organization_id: 'org_b' },
179+
{ id: 'ups_two', organization_id: null },
180+
]);
181+
expect(writes.filter((w) => /^DELETE/i.test(w) && /sys_user_permission_set/.test(w))).toEqual([]);
182+
});
183+
});
184+
185+
describe('single: the Default Organization takes every NULL grant, as before', () => {
186+
it('no grant is attributed through a membership', async () => {
187+
const reviewed = await plan('single');
188+
const table = reviewed.tables.find((t) => t.object === 'sys_user_permission_set')!;
189+
expect(table.attribution).toEqual({ 'default-organization': 4 });
190+
expect(table.unattributable).toEqual([]);
191+
});
192+
});

‎packages/cli/src/utils/organization-ownership-inventory.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci
230230
* platform states. Compile-time: both directions of `extends` must hold, so a
231231
* key added to either side without the other fails `tsc` on this file.
232232
*/
233-
type HostAuthoredKeys = Exclude<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external' | 'underivableDeparture'>;
233+
type HostAuthoredKeys = Exclude<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external' | 'underivableDeparture' | 'soleMembership'>;
234234
type SpecRowKeys = OrganizationOwnershipSupplementEntry extends infer Row ? (Row extends unknown ? keyof Row : never) : never;
235235
const KEYS_AGREE: [HostAuthoredKeys] extends [SpecRowKeys] ? ([SpecRowKeys] extends [HostAuthoredKeys] ? true : false) : false = true;
236236

@@ -270,7 +270,7 @@ describe('the inventory row and the spec\'s supplement row are one shape', () =>
270270
// anchor, category and departure form) is declared in the spec: a host can
271271
// author anything the platform's own rows say.
272272
const refused: string[] = [];
273-
for (const { census: _census, cloudCarried: _cloud, external: _external, underivableDeparture: _underivable, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
273+
for (const { census: _census, cloudCarried: _cloud, external: _external, underivableDeparture: _underivable, soleMembership: _membership, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
274274
const result = OrganizationOwnershipSupplementEntrySchema.safeParse(row);
275275
if (!result.success) refused.push(`${row.object}: ${result.error.issues[0]?.message}`);
276276
}

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ export interface InventoryEntry {
168168
* host declares no deletion, so the spec's supplement row has no such key.
169169
*/
170170
underivableDeparture?: Omit<InventoryDeparture, 'as' | 'where'>;
171+
/**
172+
* Fate 3 only, platform-only, under a wall only ({@link SoleMembershipAnchor}):
173+
* tried after the anchors, on the NULL rows they left. A host's objects have
174+
* no membership semantics the platform can read, so the spec's supplement row
175+
* has no such key.
176+
*/
177+
soleMembership?: SoleMembershipAnchor;
171178
categories?: readonly InventoryCategory[];
172179
/**
173180
* Fate 4 only: the column is not the tenancy anchor, so it never receives
@@ -181,6 +188,26 @@ export interface InventoryEntry {
181188
note?: string;
182189
}
183190

191+
/**
192+
* A NULL row about a user takes the organization of that user's sole
193+
* membership: every stamped `sys_member` row of the user named by `userKey`
194+
* names one organization. It is where the runtime settles a self-registration
195+
* grant under a wall, on the first membership (ruling record 6257510973 item
196+
* 1, Q1 A). A user in several organizations, or in none, leaves the row
197+
* reported: which membership came first is not recorded, so the owner is never
198+
* guessed (ADR-0131 D10 fate 4). So is a row whose key another row of the table
199+
* already holds in that organization (`userKey` with any one of `keyColumns`):
200+
* the grant's unique index would refuse the write, and the operator keeps one.
201+
* Under `single` the Default Organization takes the row, as before.
202+
*/
203+
export interface SoleMembershipAnchor {
204+
userKey: string;
205+
keyColumns: readonly string[];
206+
}
207+
208+
/** The object whose rows are a user's memberships, each naming its organization. */
209+
export const MEMBERSHIP_OBJECT = 'sys_member';
210+
184211
// ── Citations, spelled once ───────────────────────────────────────────────────
185212

186213
const CENSUS_1 = 'census record 5479883784 (2026-08-31, at 00d8f6541b)';
@@ -649,7 +676,17 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
649676
anchors: [parent('business_unit_id', 'sys_business_unit')],
650677
},
651678
{ 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')] },
652-
{ object: 'sys_user_permission_set', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS} (assignments are organization rows); ${D10_SINGLE}`, anchors: [] },
679+
{
680+
object: 'sys_user_permission_set',
681+
census: P1,
682+
fate: 'attribution',
683+
citation:
684+
`${D3_ORG_ROWS} (assignments are organization rows); under a wall, a self-registration grant written before ` +
685+
'the grant waited for the first membership (ruling record 6257510973 item 1) takes the user\'s sole ' +
686+
`membership's organization; ${D10_SINGLE}`,
687+
anchors: [],
688+
soleMembership: { userKey: 'user_id', keyColumns: ['permission_set', 'permission_set_id'] },
689+
},
653690
{
654691
object: 'sys_audience_binding_suggestion',
655692
census: P1,

0 commit comments

Comments
 (0)