Skip to content

Commit cc5645c

Browse files
test(dogfood): a walled leg of the sign-up organization census (#25241)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgCq1w2KRx6KxuLkmsCrYWrr)_ Fixes #25207 Part of #24343 (its ceremony half, the `advisory` count, is not addressed here; #24343 remains open). Clause-②: no ## What A walled leg of the sign-up organization census, as a sibling file beside the `single` one: `packages/qa/dogfood/test/signup-organization-census-walled.dogfood.test.ts`. Test-only; nothing outside `packages/qa/dogfood/` moves, and the `single` file is untouched. One boot per posture (`group`, `isolated`), `multiTenant: 'posture-only'` with `OS_TENANCY_POSTURE` selecting the posture, and the audit and email plugins composed together. The journey: 1. the admin opens the audience (`open`, declared set `member_default`); 2. a stranger self-registers (verification mail) and creates an organization (first membership); 3. they invite a second stranger (invitation mail), who signs up and accepts (first membership). Then a census of the STORE, not the registry: every sqlite table carrying an `organization_id` column is read from `sqlite_master` plus `PRAGMA table_info` at run time (39 tables on this boot, the date-named `sys_activity` shard included), so a new table joins the census on its own. | assertion | reads | |:--|:--| | guard | posture is `group` / `isolated`, not degraded, isolation active; both users' only membership is the journey's organization; the census reaches the writers' tables | | whole-store NULL census | 0 rows with NULL or empty `organization_id` on every tenant-column table (boot rows included) | | journey rows | every row the journey wrote on a tenant-column table names the organization the users joined (17 rows: activity 10, `sys_member` 2, `sys_user_permission_set` 2, `sys_email` 1, `sys_invitation` 1, `sys_team` 1) | | grant writer | the self-registrant holds exactly one `member_default` grant, owned by the organization; the invitee, admitted by its invitation, holds none | | activity writer | activity about each grant and about the invitation mail names the organization; no activity row about the org-less `sys_user` records | | mail writers | the invitation is tenant mail owned by the organization; each verification mail is identity mail (`sys_identity_email`, no column), none in `sys_email` | ## Why the existence legs matter Measured on this boot: 38 of the 39 tenant columns are NOT NULL in the store (`sys_sso_provider` is the one nullable column), and a stamp-less system write on a walled posture is refused by the engine (ADR-0131 D9) before it reaches the column. So "0 NULL rows" alone holds even when a writer loses its stamp: the row is refused, not written NULL. The census therefore also requires each writer's own row to exist and to name the organization, and that is the leg the ablation turns red. ## Tests At head `70551508`, through `os-verify-lock.sh`: - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/signup-organization-census-walled.dogfood.test.ts test/signup-organization-census.dogfood.test.ts`: `Test Files 2 passed (2)`, `Tests 15 passed (15)` (walled 12/12 across both postures; the `single` control 3/3). Lock VERDICT command-exit 0. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0; `tsc --listFiles` includes the new file. - `eslint --no-inline-config` on the new file: exit 0. ## Ablation (one-shot, never landed) The grant writer's stamp: in `packages/plugins/plugin-auth/src/auth-manager.ts` `landSelfRegistrationGrant`, the anchor `setName, organization_id: organizationId }` replaced by `setName, organization_id: (globalThis as any).ABLATION_25207 }` (undefined at run time), through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `ab76fa3c90e8` to `4db63fdf74d3`), then `pnpm --filter @objectstack/plugin-auth build` and `ablation-dist-preflight.mjs @objectstack/plugin-auth ABLATION_25207`: marker present in 2 built files. - Mutated: `Tests 4 failed | 11 passed (15)`. Red, in both postures: the grant writer leg (`the declared grant: expected [] to deeply equal [ orgId ]`) and the activity writer leg (`the registrant's grants: expected [ 'organization_admin' ] to include 'member_default'`). The auth log names the cause: `Insert on 'sys_user_permission_set' was REFUSED: a system-context write on a tenant-scoped object must carry an organization, and this install runs the 'group' (resp. 'isolated') tenancy posture`. The `single` control stayed green (the Default Organization is derived there). - Restored: blob back to `ab76fa3c90e8` (equal to HEAD, `git diff HEAD` empty), rebuilt, preflight `--absent` (marker absent from all 20 built files, tree clean), then `Tests 15 passed (15)`. - A first attempt planted `void "ABLATION_25207"`, which the bundler folds to `void 0`; the preflight read the marker ABSENT from `dist/`, so that leg's run was void and is not counted. ## Gates `dispatch-gates.mjs --commands` derived 56 families for this diff; all run locally at `70551508` and reconciled with `--ran`: 55 exit 0, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3: needs every package built; CI reads it). `check:query-options-erasure` and `check:type-check-debt` were re-run without the per-command 300 s cap after a first timed-out pass; `check-plugin-teardown-shape --self-test` after deepening the shallow clone. ## Changeset None: `packages/qa/dogfood` is private and the diff is a test file; nothing published moves (`skip-changeset`). ## Acceptance notes - The new file boots twice (one stack per posture); run alone on a shared box it read `Duration 33.90s` (import 25.10 s, tests 8.50 s), above the 10 s report line. That line is report-only and none of the declared budget classes fits a real-stack boot, so no budget is declared. - The invitee is admitted by the invitation, not by the open posture (`decideAudienceAdmission`), so it is owed no declared set by design; the file pins that reading instead of expecting a second grant. --- _Generated by [Claude Code](https://claude.ai/code/session_01UQVH1PSsJbZT4VvCZqEiRu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 279fdcf commit cc5645c

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0131 D9] The organization census of a self-registration on a WALLED
4+
// boot (`group` and `isolated`), end to end on the real stack with the audit
5+
// and email plugins composed together. The `single` leg is
6+
// `signup-organization-census.dogfood.test.ts`; this file is its walled
7+
// sibling, one boot per posture.
8+
//
9+
// The journey is the whole sign-up a walled deployment sees: a stranger
10+
// self-registers (the verification mail is sent), creates their organization
11+
// (their first membership), and invites a second stranger (the invitation
12+
// mail is sent), who signs up and accepts it (their first membership).
13+
// Three framework writers act on tenant-column objects along the way, and on
14+
// a wall none of them may fall back to an organization-less row:
15+
//
16+
// - the self-registration grant (`sys_user_permission_set`) settles on the
17+
// self-registrant's FIRST MEMBERSHIP, owned by that membership's
18+
// organization (ADR-0131 D3) — never at sign-up, when there is no
19+
// organization yet; the invitee is admitted by the invitation, not by the
20+
// open posture, and is owed no declared set;
21+
// - the activity writer (`sys_activity`) stamps the organization of the
22+
// record it is about, and writes NO row about a record that has none
23+
// (ADR-0131 D9; ruling of record 6257510973 item 3, Q3 A);
24+
// - the verification mail is identity mail, written to
25+
// `sys_identity_email`, which carries no organization column (ADR-0131
26+
// D7; ruling of record 6257510973, Q2 A); the invitation mail is tenant
27+
// mail, written to `sys_email`, owned by the inviting organization.
28+
//
29+
// The census reads the STORE, not the registry: every table of the sqlite
30+
// database that carries an `organization_id` column — activity shards and
31+
// tables no object names included — is derived from the schema at run time,
32+
// so a new table joins the census on its own. Since 18.x the engine creates a
33+
// tenant column NOT NULL, so "0 NULL rows" alone could hold vacuously (a
34+
// refused write leaves no row, and no NULL either); every writer's own row is
35+
// therefore also required to EXIST and to name the organization the user
36+
// joined.
37+
//
38+
// The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled
39+
// posture. Every writer here keys on the posture (whether an organization is
40+
// derivable), never on row isolation, so the stand-in's lack of a row wall
41+
// measures nothing away. `OS_TENANCY_POSTURE` selects `group`.
42+
43+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
44+
import crmStack from '@objectstack/example-crm';
45+
import { bootStack, type VerifyStack } from '@objectstack/verify';
46+
import { EmailServicePlugin } from '@objectstack/plugin-email';
47+
import { AuditPlugin } from '@objectstack/plugin-audit';
48+
49+
const SYS = { context: { isSystem: true } };
50+
const SET = 'member_default';
51+
const PASSWORD = 'Census-Passw0rd!';
52+
53+
/** A transport that accepts every message, so every mail persists as sent. */
54+
const acceptingTransport = {
55+
async send() {
56+
return { messageId: `census-${Math.random().toString(36).slice(2, 10)}` };
57+
},
58+
};
59+
60+
type Row = Record<string, unknown>;
61+
62+
for (const posture of ['group', 'isolated'] as const) {
63+
describe(`[ADR-0131 D9] ${posture}: a walled self-registration leaves no organization-less row`, () => {
64+
const REGISTRANT = `census-registrant-${posture}@dogfood.test`;
65+
const INVITEE = `census-invitee-${posture}@dogfood.test`;
66+
const SLUG = `census-${posture}`;
67+
let stack: VerifyStack;
68+
let ql: any;
69+
let orgId: string;
70+
let registrantId: string;
71+
let inviteeId: string;
72+
let invitationId: string;
73+
/** `table/id` of every row the store held on a tenant-column table BEFORE the journey. */
74+
const beforeJourney = new Set<string>();
75+
const previous = process.env.OS_TENANCY_POSTURE;
76+
77+
/** A raw statement on the default datasource — the store as it is, below every engine read path. */
78+
const sql = async (statement: string): Promise<Row[]> => {
79+
const rows = await ql.getDriverForObject('sys_user').execute(statement);
80+
return Array.isArray(rows) ? (rows as Row[]) : [];
81+
};
82+
/** Every table of the store that carries an `organization_id` column, read from the schema now. */
83+
const tenantColumnTables = async (): Promise<string[]> => {
84+
const tables = (await sql("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")).map((r) => String(r.name));
85+
const out: string[] = [];
86+
for (const table of tables) {
87+
const columns = await sql(`PRAGMA table_info("${table}")`);
88+
if (columns.some((c) => c.name === 'organization_id')) out.push(table);
89+
}
90+
return out;
91+
};
92+
/** Every row of every tenant-column table, keyed `table/id`. */
93+
const storeRows = async (): Promise<Map<string, Row & { table: string }>> => {
94+
const out = new Map<string, Row & { table: string }>();
95+
for (const table of await tenantColumnTables()) {
96+
for (const row of await sql(`SELECT * FROM "${table}"`)) out.set(`${table}/${String(row.id)}`, { ...row, table });
97+
}
98+
return out;
99+
};
100+
const journeyRows = async () => [...(await storeRows()).entries()].filter(([key]) => !beforeJourney.has(key)).map(([, row]) => row);
101+
const userIdOf = async (email: string): Promise<string> => {
102+
const user = await ql.findOne('sys_user', { where: { email }, ...SYS });
103+
expect(user?.id, `${email} exists`).toBeTruthy();
104+
return String(user.id);
105+
};
106+
/** Self-register through the public door; the address is then marked verified in place, so the user can sign in. */
107+
const selfRegister = async (email: string, name: string): Promise<{ id: string; token: string }> => {
108+
const signedUp = await stack.api('/auth/sign-up/email', {
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/json' },
111+
body: JSON.stringify({ email, password: PASSWORD, name }),
112+
});
113+
expect(signedUp.status, `sign-up ${email}: ${await signedUp.clone().text()}`).toBe(200);
114+
const id = await userIdOf(email);
115+
await ql.update('sys_user', { id, email_verified: true }, SYS);
116+
return { id, token: await stack.signIn(email, PASSWORD) };
117+
};
118+
119+
beforeAll(async () => {
120+
process.env.OS_TENANCY_POSTURE = posture;
121+
stack = await bootStack(crmStack as never, {
122+
multiTenant: 'posture-only',
123+
extraPlugins: [new AuditPlugin(), new EmailServicePlugin({
124+
transport: acceptingTransport as never,
125+
defaultFrom: { address: 'no-reply@example.test', name: 'Census' },
126+
})],
127+
});
128+
const adminToken = await stack.signIn();
129+
ql = await stack.kernel.getServiceAsync<any>('objectql');
130+
131+
// An open audience: a stranger self-registers, is owed the declared set,
132+
// and is sent the verification mail the open posture requires.
133+
const opened = await stack.raw('/api/settings/auth', {
134+
method: 'PUT',
135+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` },
136+
body: JSON.stringify({ values: { audience_posture: 'open', audience_self_registration_permission_set: SET } }),
137+
});
138+
expect(opened.status, `open the audience: ${await opened.clone().text()}`).toBe(200);
139+
140+
// The census is of what the JOURNEY writes; the boot's own rows are set
141+
// aside here, by table and id (they are still read by the whole-store
142+
// NULL census below).
143+
for (const key of (await storeRows()).keys()) beforeJourney.add(key);
144+
145+
// 1. The registrant self-registers and creates their organization.
146+
const registrant = await selfRegister(REGISTRANT, 'Census Registrant');
147+
registrantId = registrant.id;
148+
const created = await stack.apiAs(registrant.token, 'POST', '/auth/organization/create', { name: `Census ${posture}`, slug: SLUG });
149+
expect(created.status, `organization/create: ${await created.clone().text()}`).toBe(200);
150+
orgId = String(((await created.json()) as { id?: string }).id ?? '');
151+
expect(orgId, 'the organization was created').not.toBe('');
152+
153+
// 2. They invite a second stranger, who self-registers and accepts.
154+
const invited = await stack.apiAs(registrant.token, 'POST', '/auth/organization/invite-member', {
155+
email: INVITEE, role: 'member', organizationId: orgId,
156+
});
157+
expect(invited.status, `invite-member: ${await invited.clone().text()}`).toBe(200);
158+
invitationId = String(((await invited.json()) as { id?: string }).id ?? '');
159+
expect(invitationId, 'the invitation was issued').not.toBe('');
160+
const invitee = await selfRegister(INVITEE, 'Census Invitee');
161+
inviteeId = invitee.id;
162+
const accepted = await stack.apiAs(invitee.token, 'POST', '/auth/organization/accept-invitation', { invitationId });
163+
expect(accepted.status, `accept-invitation: ${await accepted.clone().text()}`).toBe(200);
164+
}, 180_000);
165+
166+
afterAll(async () => {
167+
await stack?.stop?.();
168+
if (previous === undefined) delete process.env.OS_TENANCY_POSTURE;
169+
else process.env.OS_TENANCY_POSTURE = previous;
170+
});
171+
172+
it('guard: a real walled posture, and each registrant\'s first membership is the organization of the journey', async () => {
173+
const tenancy = await stack.kernel.getServiceAsync<{ posture: string; degraded: boolean; isolationActive: boolean }>('tenancy');
174+
expect(tenancy.posture).toBe(posture);
175+
expect(tenancy.degraded).toBe(false);
176+
expect(tenancy.isolationActive).toBe(true);
177+
for (const userId of [registrantId, inviteeId]) {
178+
const memberships = await ql.find('sys_member', { where: { user_id: userId }, ...SYS });
179+
expect(memberships.map((m: Row) => m.organization_id), `${userId}: memberships`).toEqual([orgId]);
180+
}
181+
// The census's own reach: the store's tenant-column tables include every
182+
// one the three writers write.
183+
const tables = await tenantColumnTables();
184+
for (const table of ['sys_user_permission_set', 'sys_email', 'sys_member', 'sys_invitation']) expect(tables).toContain(table);
185+
expect(tables.some((t) => t === 'sys_activity' || t.startsWith('sys_activity__')), 'an activity table carries the column').toBe(true);
186+
});
187+
188+
it('the whole store holds 0 rows without an organization on every table that carries the column', async () => {
189+
const orgLess = [...(await storeRows()).values()]
190+
.filter((r) => r.organization_id == null || r.organization_id === '')
191+
.map((r) => `${r.table}/${String(r.id)}`);
192+
expect(orgLess, 'rows without an organization').toEqual([]);
193+
});
194+
195+
it('every row the journey wrote on a tenant-column table names the organization the users joined', async () => {
196+
const written = await journeyRows();
197+
expect(written.length, 'the journey wrote tenant rows').toBeGreaterThan(0);
198+
const foreign = written.filter((r) => r.organization_id !== orgId).map((r) => `${r.table}/${String(r.id)} -> ${String(r.organization_id)}`);
199+
expect(foreign, 'rows owned by an organization other than the journey\'s').toEqual([]);
200+
});
201+
202+
it('the grant writer: the self-registrant holds exactly one declared grant, owned by their first membership', async () => {
203+
const grants = await ql.find('sys_user_permission_set', { where: { user_id: registrantId }, ...SYS });
204+
expect(grants.filter((g: Row) => g.permission_set === SET).map((g: Row) => g.organization_id), 'the declared grant').toEqual([orgId]);
205+
// The creator's `organization_admin` (the auto org-admin grant) rides
206+
// the same membership; every grant names the organization.
207+
expect(grants.filter((g: Row) => g.organization_id !== orgId), 'grants outside the organization').toEqual([]);
208+
// The invitee was admitted by the invitation, not by the open posture, so
209+
// is owed no declared set (`decideAudienceAdmission`); whatever it holds
210+
// names the organization too.
211+
const inviteeGrants = await ql.find('sys_user_permission_set', { where: { user_id: inviteeId }, ...SYS });
212+
expect(inviteeGrants.filter((g: Row) => g.permission_set === SET), 'the invitee holds no declared grant').toEqual([]);
213+
expect(inviteeGrants.filter((g: Row) => g.organization_id !== orgId), 'invitee grants outside the organization').toEqual([]);
214+
});
215+
216+
it('the activity writer: activity about each grant and about the invitation mail names the organization', async () => {
217+
const written = await journeyRows();
218+
const activity = written.filter((r) => r.table === 'sys_activity' || r.table.startsWith('sys_activity__'));
219+
const grants = await ql.find('sys_user_permission_set', { where: { user_id: registrantId }, ...SYS });
220+
expect(grants.map((g: Row) => g.permission_set), 'the registrant\'s grants').toContain(SET);
221+
for (const grant of grants) {
222+
const about = activity.filter((a) => a.object_name === 'sys_user_permission_set' && a.record_id === grant.id);
223+
expect(about.length, `activity about the grant ${String(grant.id)}`).toBeGreaterThan(0);
224+
expect(about.map((a) => a.organization_id)).toEqual(about.map(() => orgId));
225+
}
226+
const [invitationMail] = await ql.find('sys_email', { where: { related_object: 'sys_invitation', related_id: invitationId }, ...SYS });
227+
expect(invitationMail?.id, 'the invitation mail persisted').toBeTruthy();
228+
const aboutMail = activity.filter((a) => a.object_name === 'sys_email' && a.record_id === invitationMail.id);
229+
expect(aboutMail.length, 'activity about the invitation mail').toBeGreaterThan(0);
230+
expect(aboutMail.map((a) => a.organization_id)).toEqual(aboutMail.map(() => orgId));
231+
// A record with no organization of its own (the registrant) on a wall
232+
// takes none: no activity row about it, never a NULL one.
233+
expect(activity.filter((a) => a.object_name === 'sys_user' && [registrantId, inviteeId].includes(String(a.record_id)))).toEqual([]);
234+
});
235+
236+
it('the mail writers: the invitation is tenant mail owned by the organization; each verification mail is identity mail', async () => {
237+
const [invitationMail] = await ql.find('sys_email', { where: { related_object: 'sys_invitation', related_id: invitationId }, ...SYS });
238+
expect(invitationMail?.organization_id).toBe(orgId);
239+
for (const userId of [registrantId, inviteeId]) {
240+
const identity = await ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId }, ...SYS });
241+
expect(identity.length, `${userId}: the verification mail persisted, in the identity-mail log`).toBeGreaterThan(0);
242+
expect(identity.every((m: Row) => !('organization_id' in m))).toBe(true);
243+
const tenantMail = await ql.find('sys_email', { where: { related_object: 'sys_user', related_id: userId }, ...SYS });
244+
expect(tenantMail, `${userId}: no verification mail in the tenant mail log`).toEqual([]);
245+
}
246+
const identityColumns = (await sql('PRAGMA table_info("sys_identity_email")')).map((c) => c.name);
247+
expect(identityColumns.length, 'sys_identity_email is in the store').toBeGreaterThan(0);
248+
expect(identityColumns).not.toContain('organization_id');
249+
});
250+
});
251+
}

0 commit comments

Comments
 (0)