Skip to content

Commit d65826f

Browse files
fix: framework producers on tenant-column objects name the organization they can derive (ADR-0131 D9, C8 stage 8b-0b) (#24369)
Part of #24343 Part of #15212 Clause-②: yes (widening) C8 stage 8b-0b (plan 6251466182 on #15212). Before stage 8b lifts the system-insert exemption, each framework producer that writes organization-less rows on a tenant-column object now names the organization it can derive itself (ADR-0131 D9: derived under `single`, refused otherwise). Under `single` that organization is the Default Organization, which is the owner the engine would derive, so no reader changes. Under a wall a row is stamped only where the producer has an organization. A row it cannot attribute is written exactly as before, and its fate stays the engine's (stage 8b). This PR does not lift the exemption and does not retire any NULL reader. **Why `Part of #24343` and not `Fixes`.** #24343's acceptance is "0 NULL rows on the three tables after a sign-up on a walled control plane, and the ceremony's advisory count reads 0". The walled half cannot be met by naming a derivable organization, because a sign-up has none to derive (measured below). Reaching it takes the fate decisions listed under "Not built". Merging this PR must not close the card. **Why `Clause-②: yes (widening)` when the claim said `no`.** Three exported types gain optional members: `AuditWriterOptions.getDefaultOrganizationId` (plugin-audit), and `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry (service-settings). These are additive widenings of a published surface, so those two packages take `minor`. Nothing narrows. `buildSettingAuditWriter`'s optional `logger` was briefly narrowed on this branch (head `bc7e2b791`, `{ error?; warn }`), and the contract review caught it (record 6253189824). It is now `{ warn? } | { error; warn? }`: `main`'s accept set (no sink, `{}`, `{ warn? }`) plus a sink that carries `error`. A tsc probe of those four shapes plus a typed `{ error?; warn? }` passed, then the probe was deleted. `check:optional-error-sink` and `check:durability-log-level` exit 0. ## Measured first (framework at this branch, in-process sqlite, `examples/app-crm`) A sign-up through `POST /auth/sign-up/email` with the audience opened (`audience_posture: open`, declared set `member_default`), then one global encrypted settings write (`mail.api_key`). Rows written after the snapshot: | table | `single` (this PR) | `isolated` (`multiTenant: 'posture-only'`, this PR) | |:--|:--|:--| | `sys_user_permission_set` | 1 written, 0 without an organization | 1 written, **1** without (the registrant's grant) | | `sys_email` | 1 / 0 | 1 / **1** (the verification mail) | | `sys_activity` | all stamped with the Default Organization | 12 / **12**: activity about `sys_user` (created, updated), `sys_email` (created, updated), `sys_user_permission_set`, `sys_platform_setting` ×3, `sys_setting_audit` ×3, `sys_secret` | | `sys_secret` (D4) | — | 1 / **1** (the global `mail.api_key` ciphertext) | | `sys_setting_audit` (D4) | — | all 6 rows in the table without an organization, all `scope: global` | - **A1.** No organization is derivable for any of the three sign-up writers under a wall: the registrant belongs to no organization when its grant, its verification mail and the activity about its user row are written. The activity rows are about records that have no organization (`sys_user`, `sys_platform_setting`, `sys_secret` …) or that are org-less for the same reason. Under `single`, the verification and reset mails and the activity about org-less records used to land NULL, and they now carry the Default Organization. The grant already carried it. - **A3 / D4.** The deployment-level population under a wall is **not empty**. All 17 encrypted settings declared in this tree are `scope: global` (ai ×10, auth, knowledge ×2, mail, sms ×2, storage), so every `sys_secret` row the settings producer writes from the built-in catalog is deployment-level. The datasource binder's credential rows are deployment-level by construction. No webhook producer writes `sys_secret`. Every global-rung write lands an org-less `sys_setting_audit` row. ## What changed - **plugin-auth** (`auth-manager.ts`). One reading, `derivableWriteOrganizationId()`, which is `tenancy.defaultOrgId()`: the Default Organization under `single`, none under a wall. It feeds the self-registration grant (`settleSelfRegistrationGrant`) and the five auth mails about a user: verification, password reset, account invitation, magic link and the change-email notice. - **plugin-audit** (`audit-writers.ts`, `audit-plugin.ts`). An activity row whose record has no organization, written with no session organization, takes `getDefaultOrganizationId()`. `AuditPlugin` wires that to the `tenancy` service. The record's own organization and then the session's still win, and the ledger's `tenant_id` attribution (D7) is untouched. - **service-settings** (`settings-service.ts`, `settings-service-plugin.ts`, `settings-service.types.ts`). The `sys_secret` ciphertext and the `sys_setting_audit` row of a `tenant` or `user` value carry that value's organization, the same owner as its `sys_setting` row. A `global` value's rows name none (D4, below). The setting-audit writer's `catch` now reports at `error` when the sink carries it (else `warn`), under the degradation log-level rule: the settings write succeeds while the history row is lost. Without this a refusal from stage 8b would be a silent audit loss. The first report carries the consequence and the fix. - **verify** (`rls.ts`). The RLS probe and position personas' grants belong to the persona's single membership, which under `single` is the Default Organization. They were written with an explicit `organization_id: null`. - **dogfood fixture** (`showcase-bu-hierarchy-sharing.dogfood.test.ts`, the "fixture :66"). Its `findOne('sys_organization', { where: {} })` is refused by the engine as an arbitrary pick. The `.catch(() => null)` swallowed that refusal, so the fixture always minted a stand-in `org_bu` beside the real Default Organization. It now reads the Default Organization from the `tenancy` service, and it stamps its `sys_business_unit_member` rows too. ## Not built — fates ADR-0131 D7/D9 do not settle (reported to the seat) 1. The walled self-registration grant (the registrant has no organization yet). 2. The walled auth mails about a user (`sys_email`). 3. The walled activity about org-less records (`sys_activity`). 4. D4: the deployment-level `sys_secret` (global settings secrets, datasource credentials) and `sys_setting_audit` (global rung) rows. No object leaves the tenant-column set here. The ceremony's advisory count (`packages/cli`) is not touched. It reads 0 for these tables only once 1–3 are decided. ## Tests - New `plugin-auth/src/producers-name-organization.test.ts`: the verification mail, the reset mail and the grant, each under `single` (Default Organization) and under a wall (none). - `plugin-audit/src/audit-writers.test.ts`: three cases. The Default Organization is used under `single`; none under a wall, with no resolver, or when the resolver throws; a known organization is never overridden and the resolver is not asked. - New `service-settings/src/settings-secret-organization.test.ts`: tenant value vs global value, and the audit writer's `organization_id`. `settings-user-reference.pin.test.ts` follows the `error` channel. - `verify/src/rls-persona-grant.test.ts`: the persona with one membership, none, and two. - New `dogfood/test/signup-organization-census.dogfood.test.ts`: the `single` half of #24343's acceptance census. After a real open-audience sign-up, every row the sign-up wrote on the three tables names the Default Organization. The platform admin's promotion grant, which is stage 8b-0a's, is set aside by snapshot. - **Ablation** (census, plugin-audit, through `scripts/ablation-replace.mjs` with a trap, then a rebuild): the fallback's resolver returned undefined (`return ['ABLATED'][1]`). `ablation-dist-preflight` found the marker in `dist`, and the census went red (`sys_activity: rows without an organization`, 2 rows). Restored: blob equal to HEAD, `git diff HEAD` empty, `--absent` preflight passed, 2/2 green. - Package suites at `5c3cbdd90`: plugin-auth 161 files / 3123 passed, 10 skipped; plugin-audit 56 / 897; service-settings 50 / 874; verify 41 / 363. `typecheck` passed for all four packages and for dogfood. Dogfood journeys passed 6 files / 28 tests: the census, the business-unit sharing fixture, `rls-fixture`, `admin-identity-audit-trail`, `settings-organization-isolation` and `email-template-materialization`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 77 families; all were run and reconciled with `--ran` (76 run, 1 NOT MEASURED). The NOT MEASURED one is `check:dual-build-cjs-loads`, which exited 3 (PREREQUISITE NOT MET: no full `pnpm build` on this box), so CI's reading counts for it. `check:engine-double-contract` was red once, on a `findOne` in the new grant double. It was fixed by dropping the member the writer never calls, and the re-run passed. `check-plugin-teardown-shape --self-test` needed its pinned commit fetched (shallow clone) and then passed. The family list re-derived after the last commit is identical. ## Acceptance notes - `IEmailService`'s `SendEmailInput.organizationId` TSDoc (`packages/spec`) still says auth verification and reset mail "genuinely has none". That is now true only under a wall. Not edited here (outside the file surface). - Under `single`, the platform admin's promotion grant (`admin_full_access`) still lands without an organization. That is stage 8b-0a's file (`bootstrap-platform-admin.ts`, #11979), measured by the census and set aside there. - Under a wall, `sys_audit_log.tenant_id` stays NULL on the sign-up's rows. That is D7's attribution field, not the anchor. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt --- _Generated by [Claude Code](https://claude.ai/code/session_015nNHWXUVqXs5TqaLtf2aGt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5da2e88 commit d65826f

15 files changed

Lines changed: 668 additions & 29 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
"@objectstack/service-settings": minor
4+
"@objectstack/plugin-auth": patch
5+
"@objectstack/verify": patch
6+
---
7+
8+
fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9)
9+
10+
Clause-②: yes (widening)
11+
12+
A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before.
13+
14+
- **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`.
15+
- **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service.
16+
- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`.
17+
- **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none.

‎packages/plugins/plugin-audit/src/audit-plugin.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,20 @@ export class AuditPlugin implements Plugin {
386386
const { locale } = await resolveLocalizationContext({ ql: engine, settings, tenantId, userId });
387387
return locale;
388388
};
389-
installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale });
389+
// [ADR-0131 D9] The organization an activity row about an org-less record
390+
// derives: the `tenancy` service's Default Organization under `single`,
391+
// none under a wall. Resolved at write time — AuthPlugin registers the
392+
// service, and the answer is memoized there.
393+
const getDefaultOrganizationId = async (): Promise<string | null | undefined> => {
394+
let tenancy: { defaultOrgId?: () => Promise<string | null> } | undefined;
395+
try {
396+
tenancy = ctx.getService<{ defaultOrgId?: () => Promise<string | null> }>('tenancy');
397+
} catch {
398+
return undefined;
399+
}
400+
return typeof tenancy?.defaultOrgId === 'function' ? tenancy.defaultOrgId() : undefined;
401+
};
402+
installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale, getDefaultOrganizationId });
390403
ctx.logger.info('AuditPlugin: audit + activity writers installed');
391404

392405
// [#8992] Record-view auditing — the `read` half of the ledger. Installed

‎packages/plugins/plugin-audit/src/audit-writers.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2564,3 +2564,61 @@ describe('[#21207] stored metadata copies carry no content hash', () => {
25642564
expect(JSON.parse(audit!.row.new_value).checksum).toBe(HASH);
25652565
});
25662566
});
2567+
2568+
/**
2569+
* [ADR-0131 D9] An activity row about a record with no organization of its own
2570+
* (a `sys_user`, a platform setting), written with no session organization (a
2571+
* sign-up), takes the one the host derives — the `tenancy` service's Default
2572+
* Organization under `single`, `null` under a wall. The ledger row's
2573+
* `tenant_id` is an attribution of what the row is ABOUT (D7) and is not
2574+
* touched; a known organization is never overridden.
2575+
*/
2576+
describe('audit writers — the activity row of an org-less record names the derivable organization (ADR-0131 D9)', () => {
2577+
const ORG_LESS = { ...MULTI_TENANT, sys_user: ['id', 'name', 'email'] };
2578+
const signUp = {
2579+
object: 'sys_user',
2580+
input: { id: 'usr-1' },
2581+
result: { id: 'usr-1', name: 'Ada', email: 'ada@example.com' },
2582+
session: {},
2583+
};
2584+
2585+
it('stamps the Default Organization under single, and leaves the ledger attribution alone', async () => {
2586+
const { engine, fire, created } = makeEngine(ORG_LESS);
2587+
installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId: async () => 'org-default' });
2588+
await fire('afterInsert', signUp);
2589+
2590+
expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBe('org-default');
2591+
expect(created.find((c) => c.object === 'sys_audit_log')?.row.tenant_id).toBeNull();
2592+
});
2593+
2594+
it('names none under a wall, and none when the host derives nothing or cannot answer', async () => {
2595+
for (const getDefaultOrganizationId of [async () => null, undefined, async () => { throw new Error('no tenancy'); }]) {
2596+
const { engine, fire, created } = makeEngine(ORG_LESS);
2597+
installAuditWriters(engine as any, 'test.audit', getDefaultOrganizationId ? { getDefaultOrganizationId } : {});
2598+
await fire('afterInsert', signUp);
2599+
expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBeNull();
2600+
}
2601+
});
2602+
2603+
it('never overrides a known organization — the record\'s, then the session\'s — nor asks for one', async () => {
2604+
let asked = 0;
2605+
const getDefaultOrganizationId = async () => { asked += 1; return 'org-default'; };
2606+
const { engine, fire, created } = makeEngine({ ...MULTI_TENANT, crm_lead: ['id', 'name', 'organization_id'] });
2607+
installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId });
2608+
await fire('afterInsert', {
2609+
object: 'crm_lead',
2610+
input: { id: 'lead-1' },
2611+
result: { id: 'lead-1', name: 'Acme', organization_id: 'org-A' },
2612+
session: { organizationId: 'org-B', userId: 'user-1' },
2613+
});
2614+
await fire('afterInsert', {
2615+
object: 'crm_lead',
2616+
input: { id: 'lead-2' },
2617+
result: { id: 'lead-2', name: 'Beta', organization_id: null },
2618+
session: { organizationId: 'org-B', userId: 'user-1' },
2619+
});
2620+
const activities = created.filter((c) => c.object === 'sys_activity').map((c) => c.row.organization_id);
2621+
expect(activities).toEqual(['org-A', 'org-B']);
2622+
expect(asked).toBe(0);
2623+
});
2624+
});

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ export interface AuditWriterOptions {
149149
* comes first. Absent → summaries stay English (status quo).
150150
*/
151151
getLocale?(tenantId?: string, userId?: string): Promise<string | undefined>;
152+
/**
153+
* [ADR-0131 D9] The organization an activity row carries when neither its
154+
* record nor the acting session names one: the Default Organization under
155+
* `single` (the only organization there is), `null` under a wall — the
156+
* `tenancy` service's `defaultOrgId()`. Asked only for such a row, never
157+
* for one whose organization is already known. Absent, or an answer that
158+
* throws → the row names none, as before.
159+
*/
160+
getDefaultOrganizationId?(): Promise<string | null | undefined>;
152161
}
153162

154163
/**
@@ -1445,6 +1454,14 @@ export function installAuditWriters(
14451454
// `resolveRecordOrganizationField` there for the precedence and for why the
14461455
// answer comes from the schema rather than a literal.
14471456
const recordOrgResolver = createRecordOrganizationResolver(engine);
1457+
const derivableActivityOrganization = async (): Promise<string | undefined> => {
1458+
if (!opts.getDefaultOrganizationId) return undefined;
1459+
try {
1460+
return (await opts.getDefaultOrganizationId()) ?? undefined;
1461+
} catch {
1462+
return undefined;
1463+
}
1464+
};
14481465

14491466
// Display label for an object under a given translate fn: translated label
14501467
// → authored def label → API name. Shared by activity summaries and the
@@ -2204,8 +2221,14 @@ export function installAuditWriters(
22042221
// Same rationale as auditRow: stamp the tenant column so RLS matches the
22052222
// recipient's organization on read — but only when the (auto-injected)
22062223
// column actually exists, so single-tenant activity writes don't fail.
2224+
//
2225+
// [ADR-0131 D9] A record with no organization of its own (a `sys_user`, a
2226+
// platform setting), written with no session organization (a sign-up),
2227+
// takes the derivable one: the Default Organization under `single`. Only
2228+
// the activity row: the ledger's `tenant_id` above is an attribution of
2229+
// what the row is ABOUT (D7) and stays the record's.
22072230
if (objectHasField('sys_activity', 'organization_id')) {
2208-
activityRow.organization_id = tenantId ?? null;
2231+
activityRow.organization_id = tenantId ?? (await derivableActivityOrganization()) ?? null;
22092232
}
22102233

22112234
const activityRowToWrite = mirrorsActivity ? activityRow : undefined;

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,7 @@ export class AuthManager {
20032003
// the user's mail carries only the deployment default. Best-effort;
20042004
// `undefined` leaves the #14319 ladder exactly as it was.
20052005
const storedLocale = await this.storedRecipientLocale({ id: user.id });
2006+
const mailOrganization = await this.authMailOrganizationArg();
20062007
const result = await email.sendTemplate(invitation
20072008
? {
20082009
// Exactly the variables `auth.account_invitation` declares —
@@ -2022,6 +2023,7 @@ export class AuthManager {
20222023
},
20232024
relatedObject: 'sys_user',
20242025
relatedId: user.id,
2026+
...mailOrganization,
20252027
}
20262028
: {
20272029
template: 'auth.password_reset',
@@ -2036,6 +2038,7 @@ export class AuthManager {
20362038
},
20372039
relatedObject: 'sys_user',
20382040
relatedId: user.id,
2041+
...mailOrganization,
20392042
});
20402043
if (result?.status === 'failed') {
20412044
throw new Error(
@@ -2098,6 +2101,9 @@ export class AuthManager {
20982101
},
20992102
relatedObject: 'sys_user',
21002103
relatedId: user.id,
2104+
// [ADR-0131 D9] The mail is about a user, who belongs to no
2105+
// organization yet: the derivable owner, as for the grant.
2106+
...(await this.authMailOrganizationArg()),
21012107
});
21022108
if (result?.status === 'failed') {
21032109
throw new Error(
@@ -4114,6 +4120,7 @@ export class AuthManager {
41144120
expiresInMinutes: 10,
41154121
appName: this.getAppName(),
41164122
},
4123+
...(await this.authMailOrganizationArg()),
41174124
});
41184125
} catch (err: any) {
41194126
console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`);
@@ -5618,12 +5625,41 @@ export class AuthManager {
56185625
}
56195626
}
56205627

5628+
/**
5629+
* [ADR-0131 D9] The organization a write this manager makes on a
5630+
* tenant-column object carries when nothing on the write names one: the
5631+
* Default Organization under `single` — the only organization there is —
5632+
* and none under a wall, where `tenancy.defaultOrgId()` answers `null`. One
5633+
* reading for every such producer here (the self-registration grant and the
5634+
* auth mails that are about a user rather than an organization), so they
5635+
* cannot disagree about it. `undefined` when no tenancy service is wired or
5636+
* it cannot answer: the producer then names nothing, as before.
5637+
*/
5638+
private async derivableWriteOrganizationId(): Promise<string | undefined> {
5639+
try {
5640+
const tenancy = this.config.getTenancy?.();
5641+
const organizationId = tenancy ? await tenancy.defaultOrgId() : null;
5642+
return organizationId ?? undefined;
5643+
} catch {
5644+
return undefined;
5645+
}
5646+
}
5647+
5648+
/** The `organizationId` argument of an auth mail that is about a user, not an organization. */
5649+
private async authMailOrganizationArg(): Promise<{ organizationId?: string }> {
5650+
const organizationId = await this.derivableWriteOrganizationId();
5651+
return organizationId ? { organizationId } : {};
5652+
}
5653+
56215654
/**
56225655
* Land the staged self-registration grant for a freshly created user — the
56235656
* `user.create.after` half of an admitted self-registration. Idempotent
5624-
* (existing `(user, set)` pair wins), org-scoped to the same target org the
5625-
* membership reconciler resolves (falls back to an unscoped row only when
5626-
* no org is resolvable — multi-org self-registration binds no org either).
5657+
* (existing `(user, set)` pair wins), owned by the organization
5658+
* {@link derivableWriteOrganizationId} derives — the same target org the
5659+
* membership reconciler resolves. Under a wall none is derivable when the
5660+
* registrant is created (multi-org self-registration binds no org either),
5661+
* so the row is written without one and its fate is the engine's (ADR-0131
5662+
* D9).
56275663
*
56285664
* A failure here is reported at the DURABILITY level (`error` when the sink
56295665
* carries it): the system keeps looking normal from the outside while a
@@ -5647,13 +5683,7 @@ export class AuthManager {
56475683
return;
56485684
}
56495685
const sys = withSystemReadContext(engine) as any;
5650-
let organizationId: string | null = null;
5651-
try {
5652-
const tenancy = this.config.getTenancy?.();
5653-
organizationId = tenancy ? await tenancy.defaultOrgId() : null;
5654-
} catch {
5655-
organizationId = null;
5656-
}
5686+
const organizationId = await this.derivableWriteOrganizationId();
56575687
// [ADR-0131 D3/D4] The grant names the declared set BY NAME — the name
56585688
// admission asked the security catalog and the activation ledger about —
56595689
// so no `sys_permission_set` row is read to write it, and none needs to
@@ -5675,7 +5705,7 @@ export class AuthManager {
56755705
this.config.logger?.info?.('[audience] granted the declared self-registration permission set', {
56765706
userId,
56775707
permissionSet: staged.setName,
5678-
organizationId: organizationId ?? undefined,
5708+
organizationId,
56795709
});
56805710
} catch (error) {
56815711
this.reportUngrantedSelfRegistrant(
@@ -5794,6 +5824,7 @@ export class AuthManager {
57945824
},
57955825
relatedObject: 'sys_user',
57965826
...(from.id ? { relatedId: from.id } : {}),
5827+
...(await this.authMailOrganizationArg()),
57975828
});
57985829
} catch (err) {
57995830
// Best-effort by design — see the ⛔ above. Logged so an operator can see

0 commit comments

Comments
 (0)