diff --git a/.changeset/25203-adr-0131-small-carriers.md b/.changeset/25203-adr-0131-small-carriers.md new file mode 100644 index 00000000000..8eb2e085d2c --- /dev/null +++ b/.changeset/25203-adr-0131-small-carriers.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-sharing': patch +'@objectstack/plugin-security': patch +'@objectstack/cli': patch +'@objectstack/spec': patch +--- + +fix(plugin-sharing): the declared-rule organization walk ends on the rows read, and a row with no readable id answers unknown; texts that still described the retired NULL-organization reading are corrected + +Clause-②: no +Metadata-protocol: none + +- **`@objectstack/plugin-sharing`.** The boot's organization walk for declared sharing-rule scopes (`resolveDeclaredRuleScopes`) judged "short page, stop" on the ids it kept, so a full page of `sys_organization` holding one row with no readable `id` ended the walk early and served a partial list, and the boot sweep then revoked the declared-rule grants of every organization past it. The walk now ends on the number of rows READ, and such a row makes the answer unknown: declared rules are served in no organization until the next boot and no declared rule's grant is revoked, with a warning that names the cause. Under the shipped schema every organization row has a text id, so no install is expected to see the new warning. +- **`@objectstack/plugin-security`.** The Organization help on `sys_user_position` and `sys_user_permission_set` no longer says an empty value means "global" or "applies in every org context". A grant or assignment applies only in its own organization, and one with no organization applies nowhere. The four locale bundles carry the new text. +- **`@objectstack/cli`.** `os migrate organization-ownership`'s warning about rows whose owner cannot be derived reads "…while one stays NULL, except on a table whose column is not the tenancy anchor." where it read "(but on a table …)". +- **`@objectstack/spec`.** Wording only, no schema moves: the upgrade guide's `driver-read-scope-follows-tenant-wall` entry names update and delete "by id or by predicate"; the `sharing-rule-declared-mirror-retired` entry no longer paraphrases ADR-0131 D13 as asking an entry of every retirement; `IEmailService`'s `organizationId` TSDoc no longer says an absent value leaves the row unstamped (the engine stamps the derivable organization or refuses the insert); a TSDoc citation in `schedule-organization.zod.ts` names the run store instead of a deleted file. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index f5d92607057..1466e4757de 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -46,8 +46,10 @@ now costs nothing, and the population is **re-derivable** rather than quoted. `auditMissingTenant(object, op, options)` is gated in this order: 1. `OS_TENANT_AUDIT=0` — off entirely; -2. `options.bypassTenantAudit` — the engine sets this for **every** - `ExecutionContext.isSystem` write (ObjectQL's `buildDriverOptions`); +2. `options.bypassTenantAudit` — on an `ExecutionContext.isSystem` write the + engine sets it only where the object's schema puts the write out of scope: + an object with no tenant field, or a federated one (ObjectQL's + `buildDriverOptions`). A caller may still pass it explicitly; 3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`; 4. the deployment posture — the warning is only meaningful on a walled install; 5. the object having a tenant field. diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx index 41872e35eb0..66a1c588db7 100644 --- a/content/docs/references/automation/schedule-organization.mdx +++ b/content/docs/references/automation/schedule-organization.mdx @@ -76,8 +76,8 @@ multi-plant MES example is the ADR's own). It does not. `sys_automation_run` has ALREADY been ruled to use: `ObjectStoreSuspendedRunStore` resolves a run's organization as `organizationOf() ?? ctx.tenantId` — subject first, the acting context as the fallback and *never* the primary -(`objectql/src/tenancy/platform-object-tenancy.ts`, the `sys_automation_run` -evidence line). Before this card the two halves disagreed under `group`: the +(`service-automation/src/suspended-run-store.ts`, where the store stamps a +run's row). Before this card the two halves disagreed under `group`: the history row was stamped from the record while the inbox and delivery rows followed an acting context that could not exist there, so they were refused. Filling the acting context from the record makes one run carry ONE diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index c281ce101b2..7abd5b5e3a9 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -632,7 +632,7 @@ export default class OrganizationOwnershipCeremony extends Command { printWarning( `${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id, with why, in the plan ` + 'file. The ceremony neither guesses their owner nor deletes them, and --apply does not certify the database ' + - 'while one stays NULL (but on a table whose column is not the tenancy anchor): assign each its ' + + 'while one stays NULL, except on a table whose column is not the tenancy anchor. Assign each its ' + 'organization, or delete it by hand, before or after --apply, and re-run it.', ); } @@ -1121,7 +1121,7 @@ export default class OrganizationOwnershipCeremony extends Command { } console.log(''); // Unattributable rows hold certification (ruling C on ADR-0131 D10 fate - // 4) but on a table whose column is not the tenancy anchor; `advisory` + // 4), except on a table whose column is not the tenancy anchor; `advisory` // counts those and the advisory departures, each said in its own terms. const unattributableHeld = report.tables.reduce((n, t) => n + (t.held?.rows ?? 0), 0); const unattributableReported = report.tables.reduce((n, t) => n + t.unattributable.length, 0) - unattributableHeld; diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 602aeb7c482..7c99b779d80 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -3801,7 +3801,7 @@ export const UNATTRIBUTABLE_REMEDY = /** * Unattributable rows the post-check holds against certification: every one, - * but on a fate-4 table whose column is not the tenancy anchor + * except on a fate-4 table whose column is not the tenancy anchor * (`InventoryEntry.notNullExempt`), where an organization-less row is a * legitimate one and is reported only. */ diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 72b5b4afc13..c16a9940aeb 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -110,7 +110,7 @@ export const SysUserPermissionSet = ObjectSchema.create({ organization_id: Field.lookup('sys_organization', { label: 'Organization', required: false, - description: 'Optional organization scope. NULL = applies in every org context.', + description: 'The organization this grant belongs to.', }), // Provenance, not a business link: the platform writes it, a caller does diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index fbd17436adf..321e998858d 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -16,8 +16,9 @@ import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './res * * `position` stores the position's machine name (matches * `sys_position.name`), mirroring how `ctx.positions` is keyed everywhere - * downstream. `organization_id = null` means a cross-tenant (global) - * assignment. + * downstream. `organization_id` is the organization the assignment belongs + * to: it applies only while that organization is the active one, and an + * organization-less row applies nowhere (ADR-0131 D1). * * `business_unit_id` is the ASSIGNMENT-LEVEL BU anchor (ADR-0090 Addendum; * reserved by ADR-0057 D4). Positions never bind to a business unit at the @@ -107,7 +108,7 @@ export const SysUserPosition = ObjectSchema.create({ organization_id: Field.lookup('sys_organization', { label: 'Organization', required: false, - description: 'Tenant that owns this assignment; null = global (cross-tenant).', + description: 'The organization this assignment belongs to.', }), // Provenance, not a business link — the same contract as diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index e62ef941a00..81a27f41254 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -38,7 +38,7 @@ export const enObjects: NonNullable = { }, organization_id: { label: "Organization", - help: "Optional organization scope. NULL = applies in every org context." + help: "The organization this grant belongs to." }, granted_by: { label: "Granted By", @@ -104,7 +104,7 @@ export const enObjects: NonNullable = { }, organization_id: { label: "Organization", - help: "Tenant that owns this assignment; null = global (cross-tenant)." + help: "The organization this assignment belongs to." }, granted_by: { label: "Granted By", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 708ead1d327..5a90466ed39 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -38,7 +38,7 @@ export const esESObjects: NonNullable = { }, organization_id: { label: "Organización", - help: "Ámbito de organización opcional. NULL = se aplica en cualquier contexto de organización." + help: "La organización a la que pertenece esta asignación." }, granted_by: { label: "Concedido por", @@ -104,7 +104,7 @@ export const esESObjects: NonNullable = { }, organization_id: { label: "Organización", - help: "Inquilino propietario de esta asignación; nulo = global (entre inquilinos)." + help: "La organización a la que pertenece esta asignación." }, granted_by: { label: "Otorgado por", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index d3f95540cd3..2152d45e631 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -38,7 +38,7 @@ export const jaJPObjects: NonNullable = { }, organization_id: { label: "組織", - help: "オプションの組織スコープ。NULL = すべての組織コンテキストで適用。" + help: "この付与が属する組織。" }, granted_by: { label: "付与者", @@ -104,7 +104,7 @@ export const jaJPObjects: NonNullable = { }, organization_id: { label: "組織", - help: "この割り当てを所有するテナント。Null = グローバル(テナント横断)。" + help: "この割り当てが属する組織。" }, granted_by: { label: "付与者", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index f4f213c20bc..4b78f73302a 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -38,7 +38,7 @@ export const zhCNObjects: NonNullable = { }, organization_id: { label: "组织", - help: "可选的组织范围。NULL = 在所有组织上下文中都生效。" + help: "此授权所属的组织。" }, granted_by: { label: "授权人", @@ -104,7 +104,7 @@ export const zhCNObjects: NonNullable = { }, organization_id: { label: "组织", - help: "拥有此分配的租户;为空 = 全局(跨租户)。" + help: "此分配所属的组织。" }, granted_by: { label: "授予人", diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 748007d52fe..e19ba4a77b1 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -81,13 +81,15 @@ const DECLARED_RULE_ORGANIZATION_PAGE = 500; * [ADR-0131 D2] The scopes declared sharing rules are served in * (`SharingRuleService.setDeclaredRuleScopes`): exactly one organization-less * scope under `single`, and under a wall EVERY organization, read by a keyset - * walk on `id` until a short page. These are the passes the retired seeder - * made, as reads (#10103). + * walk on `id` until a short page — short as READ, never as kept: a page that + * came back full is followed, whatever it held. These are the passes the + * retired seeder made, as reads (#10103). * - * `null` when the enumeration failed at any page: an unreadable - * `sys_organization` means "we do not know", never "there are no tenants" — - * and never a partial list, because the scopes decide which name-keyed grants - * the boot sweep keeps. The caller marks the scopes UNKNOWN + * `null` when the enumeration failed at any page, or a page answered a row + * whose `id` the walk cannot read: an unreadable `sys_organization` means "we + * do not know", never "there are no tenants" — and never a partial list, + * because the scopes decide which name-keyed grants the boot sweep keeps. The + * caller marks the scopes UNKNOWN * (`SharingRuleService.setDeclaredRuleScopesUnknown`): no declared rule is * served, and no declared rule's grant is revoked. */ @@ -116,11 +118,22 @@ export async function resolveDeclaredRuleScopes( 'and restart.', { error: e?.message, read: ids.length }); return null; } - const page = (Array.isArray(rows) ? rows : []) + const read: any[] = Array.isArray(rows) ? rows : []; + const page = read .map((r: any) => r?.id) .filter((id: unknown): id is string => typeof id === 'string' && id !== ''); + // A row without a readable id is a failed read, not a shorter page: judged + // on the kept ids, a full page holding one would end the walk early and + // answer the partial list this function never answers. + if (page.length !== read.length) { + logger?.warn?.('SharingServicePlugin: sys_organization answered a row with no readable id — declared ' + + 'sharing rules are served in NO organization until the next boot, and no declared rule\'s grant is ' + + 'revoked meanwhile (the standing grants keep answering). Remedy: give every sys_organization row ' + + 'its id and restart.', { read: ids.length }); + return null; + } ids.push(...page); - if (page.length < DECLARED_RULE_ORGANIZATION_PAGE) return ids; + if (read.length < DECLARED_RULE_ORGANIZATION_PAGE) return ids; after = page[page.length - 1]; } } diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index 4533d8c3655..43a43d4e370 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -2370,6 +2370,20 @@ describe('[ADR-0131 D2] resolveDeclaredRuleScopes', () => { expect(engine.calls()).toBe(3); }); + it('a FULL page holding a row with no readable id answers unknown (null), never the partial list its kept ids would end on', async () => { + // The end of the walk is judged on the rows READ: 500 rows with one id + // filtered out is a full page, not a short one. + const engine = organizations(1201); + const find = engine.find; + engine.find = async (o: string, opts?: any) => { + const rows = await find(o, opts); + return opts?.where ? rows : rows.map((r: Row, i: number) => (i === 9 ? { ...r, id: undefined } : r)); + }; + const warns: string[] = []; + expect(await resolveDeclaredRuleScopes(engine, 'isolated', { warn: (m) => warns.push(m) })).toBeNull(); + expect(warns).toEqual([expect.stringMatching(/no readable id — .*no declared rule's grant is revoked/)]); + }); + it('a read that fails on a LATER page answers unknown (null), never the partial list, and says so', async () => { const warns: string[] = []; const scopes = await resolveDeclaredRuleScopes(organizations(1201, 2), 'isolated', { warn: (m) => warns.push(m) }); diff --git a/packages/services/service-messaging/src/delivery-claim-tenant-audit.integration.test.ts b/packages/services/service-messaging/src/delivery-claim-tenant-audit.integration.test.ts index 8b85d07982f..bbcb7e7758d 100644 --- a/packages/services/service-messaging/src/delivery-claim-tenant-audit.integration.test.ts +++ b/packages/services/service-messaging/src/delivery-claim-tenant-audit.integration.test.ts @@ -6,12 +6,16 @@ * * ## What was measured * On a walled deployment (`OS_TENANCY_POSTURE=isolated`) the SQL driver's - * `auditMissingTenant` gate printed, for both delivery objects: + * `auditMissingTenant` gate printed, for both delivery objects, a warning + * that opens: * * [tenant-audit] updateMany on tenant-scoped object "sys_http_delivery" - * without options.tenantId — writes will not be tenant-isolated. + * without options.tenantId — … * [tenant-audit] updateMany on tenant-scoped object "sys_notification_delivery" - * without options.tenantId — writes will not be tenant-isolated. + * without options.tenantId — … + * + * What follows the dash is the driver's prescription, and it has been + * reworded since; this file asserts only the opening up to the object name. * * The audit is right that the writes are environment-wide. The fix is the * classification it demands, not the quiet: each `multi: true` write on the diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts index 07cc5b5b9b7..7259176ef32 100644 --- a/packages/spec/scripts/liveness/empty-state-registry.mts +++ b/packages/spec/scripts/liveness/empty-state-registry.mts @@ -107,16 +107,6 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [ evidence: 'packages/spec/src/security/sharing.zod.ts; packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts (criteria_json required: true, ADR-0113); packages/plugins/plugin-sharing/src/rule-hooks.ts (shape guard)', }, - { - file: 'packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts', - property: 'organization_id', - semantics: 'open', - rationale: - "A NULL organization scope on a user↔permission-set grant means the grant is UNSCOPED — it applies in every org context. Deliberate and load-bearing rather than an oversight: ADR-0095 D3 / ADR-0068 D2 DERIVE the platform_admin posture from an unscoped `admin_full_access` user grant specifically, and an org-SCOPED grant of the same set must not confer it. So the empty state is not merely wider, it is the distinguishing input to the highest privilege in the system — which is exactly why it belongs on this list rather than being left to memory. `explain-engine.ts` recomputes the identical predicate on purpose so the explain panel's posture cannot sit higher than enforcement's.", - evidence: - 'packages/core/src/security/resolve-authz-context.ts (resolveAuthzContext.hasPlatformAdminGrant — the single source of truth) and packages/plugins/plugin-security/src/explain-engine.ts (the identical predicate, replicated so the panel cannot overstate)', - }, - { file: 'packages/spec/src/automation/approval.zod.ts', property: 'minApprovals', diff --git a/packages/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index e3f39e7ce14..8d2972ea602 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -73,8 +73,8 @@ import { z } from 'zod'; * `sys_automation_run` has ALREADY been ruled to use: `ObjectStoreSuspendedRunStore` * resolves a run's organization as `organizationOf() ?? ctx.tenantId` * — subject first, the acting context as the fallback and *never* the primary - * (`objectql/src/tenancy/platform-object-tenancy.ts`, the `sys_automation_run` - * evidence line). Before this card the two halves disagreed under `group`: the + * (`service-automation/src/suspended-run-store.ts`, where the store stamps a + * run's row). Before this card the two halves disagreed under `group`: the * history row was stamped from the record while the inbox and delivery rows * followed an acting context that could not exist there, so they were refused. * Filling the acting context from the record makes one run carry ONE diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 9e22ede9d41..e8844a649e5 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -77,8 +77,11 @@ export interface SendEmailInput { * export and cleanup script that filters by organization, which is worse * than a null. Producers that already hold one (e.g. the messaging email * channel's `delivery.notification.organizationId`) thread it here; omit - * it when the caller genuinely has none — absent stays legal and the row - * is simply unstamped. Mail about a person rather than an organization + * it when the caller genuinely has none — absent stays legal, and the + * engine then stamps the one organization it can derive for the row or + * refuses the insert where it can derive none (ADR-0131 D9); the row stays + * unstamped only in a composition that registers no organization object. + * Mail about a person rather than an organization * (auth verification, password reset) is {@link identityMail} instead. Forward-stamping only; pre-existing org-less rows are not * backfilled. */ diff --git a/packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts b/packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts index f9be260b89a..4d599cbc4f9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts +++ b/packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts @@ -16,8 +16,8 @@ export const entry: SemanticMigration = { // No backticks in `surface`: build-upgrade-guide.ts renders it inside a // code span AND a table cell. surface: - 'ObjectQL find, findOne, count, aggregate and the update and delete predicates on an object ' - + 'with an organization column, called with an execution context that names an organization: ' + 'ObjectQL find, findOne, count, aggregate, and update and delete by id or by predicate, on an ' + + 'object with an organization column, called with an execution context that names an organization: ' + 'the DriverOptions tenantId and tenantIds the engine hands every driver', replacement: 'the driver read scope is the organization wall\'s verdict (computeTenantLayer0Verdict, ' diff --git a/packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts index 866a0484c75..5396e530c3d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.sharing-rule-declared-mirror-retired.ts @@ -29,8 +29,9 @@ export const entry: SemanticMigration = { + 'standing access is orphaned — and only then can the ceremony\'s verified marker mean zero NULL ' + 'rows. The seeder is machinery, not an authorable shape: no key of any spec schema moves, and a ' + 'stack authors `sharingRules` exactly as before, so no stored-metadata rewrite exists and this ' - + 'entry is the retirement note and the notification channel (ADR-0131 D13: each retirement is an ' - + 'ADR-0087 entry). ADR-0131 D2, D10, D13; ADR-0057 D6 (the runtime shape is canonical).', + + 'entry is the retirement note and the notification channel (registered by choice: ADR-0131 D13 ' + + 'asks an ADR-0087 entry of every retirement of an authorable shape, and the seeder is not one). ' + + 'ADR-0131 D2, D10, D13; ADR-0057 D6 (the runtime shape is canonical).', acceptanceCriteria: 'A fresh boot in every posture writes no `sys_sharing_rule` row for a declared rule and the rule ' + 'still grants; removing the declaration stops its grant; no consumer deletes a declared rule ' diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 70491000325..9aecefe6bc5 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -1408,8 +1408,10 @@ async function bootClaimed( // lowercases both the stored address and the registrant's. email: email.trim().toLowerCase(), status: 'pending', - // A dedicated org id so fixtures counting THEIR invitations never - // see these rows. + // A dedicated org id so fixtures counting THEIR invitations under a + // wall never see these rows. On `single`, which enforces no + // organization wall (ADR-0131 D8, D11), an organization owner's + // read returns them too. organization_id: 'org_verify_audience_gate', role: 'member', inviter_id: 'usr_verify_audience_gate',