From 05b4e397b407ea62c6d0d06cf6b397dce152bf2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:57:48 +0000 Subject: [PATCH 1/3] wip(cli): sole-membership anchor for walled organization-less self-registration grants Claude-Session: https://claude.ai/code/session_01CpKhP8iqtGJKZHZ5PhdNq2 Co-authored-by: Claude --- ...on-ownership-apply.sole-membership.test.ts | 188 ++++++++++++++++++ .../organization-ownership-inventory.test.ts | 4 +- .../utils/organization-ownership-inventory.ts | 39 +++- .../src/utils/organization-ownership-plan.ts | 105 +++++++++- 4 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts diff --git a/packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts b/packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts new file mode 100644 index 00000000000..eb5d8adf7bc --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts @@ -0,0 +1,188 @@ +// 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[]; + }, + 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 => + buildOrganizationOwnershipPlan({ reader: executor(), posture, database: database() }); + +/** Plan, then apply that reviewed plan, as the command does. */ +async function apply(posture: string, l = ledger()): Promise { + 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), + }, + catalog: { async resolve() { return 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[] => + db.prepare(`SELECT id, organization_id FROM sys_user_permission_set ORDER BY id`).all() as Record[]; + +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(1); + 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([]); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-inventory.test.ts b/packages/cli/src/utils/organization-ownership-inventory.test.ts index 5f76295d142..627880f4f72 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.test.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.test.ts @@ -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; +type HostAuthoredKeys = Exclude; 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; @@ -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}`); } diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 5158881c418..9669ff11707 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -168,6 +168,13 @@ export interface InventoryEntry { * host declares no deletion, so the spec's supplement row has no such key. */ underivableDeparture?: Omit; + /** + * 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 @@ -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)'; @@ -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, diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index f8f8801f998..0c0de8e1e9a 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -8,6 +8,7 @@ import { APPLICATION_OBJECT_FATE, LEGACY_SHIPPED_SET_SEEDS, LEGACY_SHIPPED_SET_SEEDS_CATEGORY, + MEMBERSHIP_OBJECT, ORGANIZATION_OWNERSHIP_INVENTORY, advisoryDepartureReason, type Anchor, @@ -16,6 +17,7 @@ import { type OrganizationOwnershipFate, type RowCondition, type RowPredicate, + type SoleMembershipAnchor, } from './organization-ownership-inventory.js'; import { physicalTableListSql, rotationBaseOf } from './unmanaged-tables.js'; import { planGrantRewrite, type GrantCollision, type LegacySetRow } from './organization-ownership-grants.js'; @@ -896,6 +898,100 @@ async function applyAnchor( return left; } +/** + * Resolve rows through the inventory's {@link SoleMembershipAnchor}; returns + * the rows it could not derive. Under a wall only (the caller asks). + */ +async function applySoleMembership( + ctx: PlanContext, + anchor: SoleMembershipAnchor, + table: string, + columns: ReadonlySet, + rows: PendingRow[], + derived: Record, + assigned: AttributionAssignment[], +): Promise { + const label = `sole-membership:${anchor.userKey}->${MEMBERSHIP_OBJECT}`; + const memberDb = locate(ctx, MEMBERSHIP_OBJECT); + const memberCols = memberDb?.catalog.columns.get(MEMBERSHIP_OBJECT); + if (!memberDb || memberDb.catalog.bases.get(MEMBERSHIP_OBJECT)?.length !== 1 || !memberCols?.has('user_id') || !memberCols.has(ORG)) { + return rows.map((row) => ({ ...row, reason: `${row.reason}; ${MEMBERSHIP_OBJECT} holds no user_id / ${ORG} to derive from` })); + } + const users = [...new Set(rows.map((r) => nonEmpty(r.values[anchor.userKey])).filter((u): u is string => u !== null))]; + const memberships = new Map>(); + const msql = memberDb.sql; + const m = msql.q(MEMBERSHIP_OBJECT, MEMBERSHIP_OBJECT); + const mUser = msql.q('user_id', MEMBERSHIP_OBJECT); + const mOrg = msql.q(ORG, MEMBERSHIP_OBJECT); + for (let i = 0; i < users.length; i += IN_CHUNK) { + const chunk = users.slice(i, i + IN_CHUNK); + const found = await msql.read( + MEMBERSHIP_OBJECT, + `SELECT ${mUser} AS u, ${mOrg} AS org FROM ${m} WHERE ${mUser} IN (${chunk.map(() => '?').join(', ')}) AND ${mOrg} IS NOT NULL`, + chunk, + ); + for (const row of found) { + const set = memberships.get(String(row.u)) ?? new Set(); + set.add(String(row.org)); + memberships.set(String(row.u), set); + } + } + // The key a row would hold once owned; one already held there is the + // operator's to resolve, as is a second NULL row this walk derives onto it. + const keyColumns = anchor.keyColumns.filter((c) => columns.has(c)); + const { sql } = ctx; + const t = sql.q(table, table); + const taken = new Set(); + const left: PendingRow[] = []; + for (const row of rows) { + const user = nonEmpty(row.values[anchor.userKey]); + const orgs = user === null ? undefined : memberships.get(user); + if (user === null) { + left.push({ ...row, reason: `${row.reason}; no ${anchor.userKey}` }); + continue; + } + if (!orgs) { + left.push({ ...row, reason: `${row.reason}; user ${user} is a member of no organization` }); + continue; + } + if (orgs.size > 1) { + left.push({ ...row, reason: `${row.reason}; user ${user} is a member of ${orgs.size} organizations, so the grant's is not derivable` }); + continue; + } + const organizationId = [...orgs][0]!; + let held: string | null = null; + for (const column of keyColumns) { + const value = nonEmpty(row.values[column]); + if (value === null) continue; + const key = `${column}\u0000${user}\u0000${value}\u0000${organizationId}`; + if (taken.has(key)) { + held = `another NULL row of user ${user} with ${column} ${value}`; + break; + } + const other = await sql.read( + table, + `SELECT ${sql.q(ID, table)} AS id FROM ${t} WHERE ${sql.q(anchor.userKey, table)} = ? AND ${sql.q(column, table)} = ? AND ${sql.q(ORG, table)} = ? LIMIT 1`, + [user, value, organizationId], + ); + if (other.length > 0) { + held = `row ${String(other[0]!.id)}`; + break; + } + } + if (held !== null) { + left.push({ ...row, reason: `${row.reason}; user ${user}'s sole membership names ${organizationId}, where ${held} already holds this key` }); + continue; + } + for (const column of keyColumns) { + const value = nonEmpty(row.values[column]); + if (value !== null) taken.add(`${column}\u0000${user}\u0000${value}\u0000${organizationId}`); + } + derived[label] = (derived[label] ?? 0) + 1; + assigned.push({ id: row.id, table: row.shard, ...(ctx.database !== PRIMARY_DATABASE ? { database: ctx.database } : {}), organizationId, via: label }); + } + return left; +} + /** * Overlay-key columns that arrived after 17.x: `sys_metadata.bundle_member` * first shipped in 18.0.0-next.0. Both the plan and the apply boot with schema @@ -1173,7 +1269,13 @@ async function walkNullRows( const { sql } = ctx; const t = sql.q(table, table); const id = sql.q(ID, table); - const anchorCols = [...new Set((entry.anchors ?? []).flatMap(anchorColumns))]; + const membership = entry.fate === 'attribution' && ctx.posture !== 'single' ? entry.soleMembership : undefined; + const anchorCols = [ + ...new Set([ + ...(entry.anchors ?? []).flatMap(anchorColumns), + ...(membership ? [membership.userKey, ...membership.keyColumns].filter((c) => columns.has(c)) : []), + ]), + ]; const departureSql = (entry.departures ?? []).map((d, i) => { const where = predicateSql(sql, table, columns, d.where); return { text: `CASE WHEN ${where.text} THEN 1 ELSE 0 END AS ${sql.q(`dep_${i}`, table)}`, params: where.params }; @@ -1210,6 +1312,7 @@ async function walkNullRows( if (pending.length === 0) break; pending = await applyAnchor(ctx, anchor, pending, derived, assigned); } + if (membership && pending.length > 0) pending = await applySoleMembership(ctx, membership, table, columns, pending, derived, assigned); if (pending.length > 0 && ctx.posture === 'single' && (ctx.defaultOrganizationId !== null || ctx.defaultOrganizationCreatable)) { derived['default-organization'] = (derived['default-organization'] ?? 0) + pending.length; // The plan's own run counts the rows the Default Organization `--apply` From 5b3155fe8d148f91cb052f71f6941987cad6940a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:15:57 +0000 Subject: [PATCH 2/3] test(cli): pin the sole-membership attribution under isolated and group, and single unchanged Claude-Session: https://claude.ai/code/session_01CpKhP8iqtGJKZHZ5PhdNq2 Co-authored-by: Claude --- ...rganization-ownership-apply.sole-membership.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts b/packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts index eb5d8adf7bc..6c361744dc7 100644 --- a/packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.sole-membership.test.ts @@ -95,7 +95,12 @@ async function apply(posture: string, l = ledger()): Promise { const report = await apply(posture); - expect(report.complete).toBe(true); - expect(report.blocking).toBe(1); + 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([ From f2c4fc265fb6097911db6b8ea742ae1c380f1ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 17:22:29 +0000 Subject: [PATCH 3/3] chore(changeset): sole-membership attribution of walled self-registration grants Claude-Session: https://claude.ai/code/session_01CpKhP8iqtGJKZHZ5PhdNq2 Co-authored-by: Claude --- ...self-registration-grant-sole-membership.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/25206-walled-self-registration-grant-sole-membership.md diff --git a/.changeset/25206-walled-self-registration-grant-sole-membership.md b/.changeset/25206-walled-self-registration-grant-sole-membership.md new file mode 100644 index 00000000000..61867e8d5ef --- /dev/null +++ b/.changeset/25206-walled-self-registration-grant-sole-membership.md @@ -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.