Skip to content

Organization Use Cases ​

Organization is the tenant boundary. This catalog starts from what a signed-in identity, an invited person, or another module tries to do, and derives the minimal API from that — never from the tables. Every case names the invariant it protects, its main path, and its adversarial/edge cases. The proving-test matrix for these cases lives in ORGANIZATION_CASE_MATRIX.md; nothing here is implemented yet, so read this catalog as frozen intent, not shipped behavior.

Ground truth for every case below comes from database/canonical/0020_organization.sql, 0040_identity.sql, 0050_security_rls.sql, 0060_platform.sql, 0400_cross_domain_contracts.sql, database/access/runtime_grants.sql, docs/reference/ARCHITECTURE.md, DOMAIN_MAP.md, DECISIONS.md (D04, D18, D21, D22), docs/contracts/MODULE_CONTRACTS.md, API_AND_SCHEMA_DISCIPLINE.md, APP_SHELL_AND_ONBOARDING.md, and the current src/foundation/tenancy/**, src/foundation/transactions/**, src/modules/identity/application/session-context/** code. Corrections made against the reviewer-proposed list are called out inline where they matter.

Decision-row legend. HTTP? — does this need a public route, or is it internal-only. Command/Read? — a state-changing command, a read model, or both. Merged with — the case it was folded into, if any. Module — the NestJS module that owns the write. DB objects — the canonical schema objects touched. Missing grants — whether ninaku_runtime can reach those objects today (see database/access/runtime_grants.sql); "none" means the grants already exist.


1. Provisioning, discovery, tenant context ​

O1 — Provision organization ​

Actor: any registered, signed-in identity (self-service; no invitation needed). Intent: turn a signed-in identity into the owner/administrator of a brand-new organization, in one atomic, retry-safe step. Preconditions: caller holds a live session; caller supplies a code, name, country, currency, locale, an initial business unit, at least one vertical, and a plan offering they chose for that country's Market. Invariants protected: D21 (an admitted organization always keeps at least one active administrator) — the function seeds it directly by inserting the owner's role_assignment + organization-wide access_scope in the same transaction that admits the organization; idempotency (platform.organization_provisioning_requests.idempotency_key is globally unique — a retried request with the same key returns the same organization_id, never a duplicate); no partial garbage (organization, business unit, vertical activation, subscription, membership, role assignment and access scope are inserted in one transaction — a failure after INSERT organizations and before the owner's access scope exists rolls all of it back); the API role never gets migrator credentials — platform.provision_organization is SECURITY DEFINER, REVOKE ALL ... FROM PUBLIC, and stays callable only by ninaku_migrator, never ninaku_runtime directly. Shipped in Group 0 (revision 28): the real entrypoint ninaku_runtime calls is the narrower platform.provision_organization_for_caller, which drops the caller-suppliable owner-identity/owner-role parameters entirely and resolves them itself (see Platform's SUBSCRIPTION_PLANS_USE_CASES.md SP3), then calls platform.provision_organization internally. Main path:

  1. Caller submits code, name, country, currency, locale, initial business unit, verticals, the plan_offering_id they chose, and an idempotency key.
  2. platform.provision_organization_for_caller validates the country, currency and locale against reference.*, that the country has an active Market, and that the chosen Offering belongs to that Market and is active and available now.
  3. platform.reserve_organization_provisioning_request reserves the key (or returns the already-completed result if this is a retry).
  4. platform.provision_organization validates the owner is an active human identity and the chosen role is a global, active role granting organization.account.manage, then inserts organization, business unit, vertical activations, subscription, membership, role assignment, and org-wide access scope, then platform.record_organization_provisioned writes one tenant Audit Record organization.provisioned in the same transaction: outcome success, authorization allowed, the caller as actor and the owner membership as membership, the new organization as its own organization and as the organization.organization target, and the created values as after-only changes (organization.code, organization.name, organization.default_locale, organization.default_currency and platform.plan_offering_id, the Offering the Subscription starts on). The function swaps the four request settings (app.identity_id, app.actor_identity_id, app.membership_id, app.organization_id) to the new owner for the capture, so the existing audit.create_record, audit.add_record_target, audit.add_record_attribute and audit.seal_record validate it unchanged, and restores them afterwards.
  5. platform.complete_organization_provisioning_request marks the request completed with the new organization_id. Adversarial / edge cases:
  • Retried request with the same idempotency key but a different payload — fingerprint mismatch is rejected, not silently merged.
  • Retried request with the same idempotency key and the same payload after a prior success — returns the same organization_id, no new row. The stored result is answered before the Market-active, Offering-available and Offering-public checks (migration 0052), so a client that lost the response still gets the id after staff paused the offering, closed the market or made the offering private; a NEW key is still refused by those checks, and the same key with a different payload is rejected as a mismatch even then.
  • The creation is audited in the tenant store (audit.records), not in audit.platform_records: the organization is the tenant and the owner is its member from that transaction on, so its members read the record of how it began. A refused or rolled-back provisioning leaves no record (the record is written inside the same transaction), and a replay of a completed key returns before anything is written, so it never writes a second record. The record carries no request id, trace id or remote address: the provisioning transaction does not receive the request context, so origin_kind is system.
  • Chosen plan offering belongs to another country's Market — refused, never silently substituted.
  • Chosen plan offering is not active, or outside its availability window — refused before any insert.
  • Unknown country/currency/locale, or a country with no active Market — refused before any insert.
  • Owner identity is blocked/disabled/service kind — refused, with no organization, membership, subscription or provisioning-request row left behind.
  • Currency different from the chosen Offering's currency — accepted on purpose, not an oversight. The organization's default_currency is an initial business preference (CURRENCY_AND_LANGUAGE.md: it does not replace a document's currency; D11 keeps currencies independent) and O5 lets the owner change it freely afterwards, so equality with the Offering would only hold until the first profile edit. What Ninaku bills is plan_offerings.currency_code, which the subscription keeps unchanged. The currency is part of the idempotency fingerprint: the same key with another currency is a different request (409 idempotency.key_reused). Open product question for the owner: whether a future rule should require equality. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /organizationsCommand—Organization owns the route; calls platform.provision_organization_for_caller over the ordinary ninaku_runtime poolorganization.organizations, organization.business_units, platform.business_unit_verticals, platform.subscriptions, identity.memberships, identity.role_assignments, identity.access_scopes, platform.organization_provisioning_requestsNone — shipped in Group 0. ninaku_runtime has EXECUTE on platform.provision_organization_for_caller; platform.provision_organization itself stays barred.

O2 — Discover my organizations ​

Actor: any signed-in identity, before any tenant context is selected. Intent: see which organizations I can enter. Preconditions: live session; no X-Organization-Id required. Invariants protected: an identity only discovers organizations tied to its own active memberships — identity.memberships carries a memberships_select_policy that allows identity_id = current_identity_id() regardless of tenant context, so this read never needs has_active_tenant_context() to be true. Main path:

  1. Caller requests the list with a live session, no organization header.
  2. Query joins identity.memberships (status active) → organization.organizations (status active) for identity_id = caller.
  3. Response returns id, name, code, and the caller's own membership status per organization — paginated by keyset, not offset/limit (per API_AND_SCHEMA_DISCIPLINE.md). Adversarial / edge cases:
  • Identity with zero memberships — empty page, not an error.
  • Identity with a suspended/ended membership — that organization is excluded (or shown with an explicit membershipStatus, decision left to the API design step; either way it is never presented as selectable).
  • Identity with a membership in a suspended/archived organization — organization appears only if the API explicitly documents that state; never presented as selectable. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — GET /organizationsRead model—Identity (owns memberships) reads across into organization.organizations; exposed as an Organization-facing read contract per D18's "read contract for compositions" modeidentity.memberships, organization.organizationsNone — ninaku_runtime already has SELECT on identity.memberships and organization.organizations; memberships_select_policy scopes the read to the caller

O3 — Select organization / establish tenant context ​

Actor: any signed-in identity with at least one active membership. Intent: make one organization the tenant boundary for the requests that follow. Preconditions: live session; caller presents an organization identifier the coordinator's proposed contract carries as the X-Organization-Id header on every tenant-scoped request (see Derived API for the alternative). Invariants protected: RLS tenant isolation — TenantContextRunnerService.apply sets the five app.* GUCs and immediately calls identity.assert_tenant_context(), which fails closed (42501) unless identity.has_active_tenant_context() finds a live, active membership row matching identity_id, organization_id, and membership_id all at once, with the organization itself active. Main path:

  1. Caller sends X-Organization-Id: <uuid> alongside a live session on any tenant-scoped request.
  2. A guard resolves the caller's active membership_id for that (identity_id, organization_id) pair.
  3. TenantTransactionRunnerService.run(context, work) opens the transaction and calls TenantContextRunnerService.apply, setting the GUCs and asserting tenant context before work runs.
  4. On success, the request proceeds with RLS scoped to that organization. Adversarial / edge cases:
  • Known UUID of a foreign organization, no membership there — the guard never finds a matching membership; refused before any GUC is even set, answers 403 · tenant.context_rejected.
  • Membership exists but is suspended/ended — has_active_tenant_context() requires m.status = 'active'; refused the same way.
  • Organization exists but its own status is not active — refused the same way (o.status = 'active' is part of the same EXISTS).
  • Header omitted entirely on a route that requires tenant context — refused by the same guard (no organization to resolve).
  • Impersonation: the impersonation session must itself be live (ended_at IS NULL, inside its started_at/expires_at window) and must name the same actor/organization/membership triple, or the same refusal applies. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
No dedicated route: TenantContextResolver (foundation tenancy port, implemented by Identity) resolves the caller's active membership for an organization, and the foundation TenantContextGuard applies it on every module's tenant-scoped routesCommand (mutates session GUCs for the transaction)Shares its check with O4's organization-availability readIdentity implements the port; TenantContextRunnerService (foundation tenancy) applies the contextidentity.memberships, organization.organizations, identity.has_active_tenant_context(), identity.assert_tenant_context()None. ninaku_runtime can read identity.memberships and identity.current_membership_id(); it needs no grant on identity.current_impersonation_session_id() because has_active_tenant_context() is SECURITY DEFINER

O4 — Read current organization context (GET /session/context) ​

Actor: any signed-in identity. Intent: know, in one call, who I am, whether I have a tenant selected, and whether I have any organization to select. Preconditions: live session. Invariants protected: the two-request screen-load budget in API_AND_SCHEMA_DISCIPLINE.md (GET /session/context + one workspace call); never inferring organization state from the client — the server always recomputes hasAvailableOrganizations and the resolved organization from the database, never from a client-supplied flag. Main path:

  1. Caller requests GET /session/context, optionally with X-Organization-Id.
  2. GetSessionContextUseCase resolves identity context (as it does today) and, additionally, resolves organization context: if the header names an organization the caller has an active membership in, return that organization plus the caller's active role(s)/permission summary; if absent or invalid, return selectedOrganizationId: null.
  3. Independently of the header, resolve hasAvailableOrganizations from a real EXISTS over identity.memberships (today this is a hardcoded false — see Adversarial/edge cases). Adversarial / edge cases:
  • Today's implementation is a stub. src/modules/identity/application/session-context/get-session-context.use-case.ts always returns organizationContext: { selectedOrganizationId: null, hasAvailableOrganizations: false } regardless of the caller — this case is entirely unimplemented past the identity-only response and must be built, not adjusted.
  • X-Organization-Id names a foreign organization — the response must answer as if the header were absent (selectedOrganizationId: null), not leak whether that organization exists; the request is not refused outright here because this route also serves callers who have no context yet.
  • Identity has memberships but all are suspended/ended — hasAvailableOrganizations is false. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — GET /session/context (existing route, extended)Read modelShares its guard with O3Identity (owns the route and the identity half); reads Organization's organization.organizations and identity.memberships for the org halfidentity.memberships, organization.organizationsSame as O2/O3

2. Organization profile and lifecycle ​

O5 — Update organization profile ​

Actor: a member with organization.account.manage at organization-wide scope. Intent: change the organization's name, default locale, or default currency. Preconditions: tenant context established (O3); caller's effective permission check passes for organization.account.manage at org-wide scope. Invariants protected: code is the organization's stable identifier (unique on lower(code)) and is never edited here — renaming the business is a name change, not a code change; default_locale/default_currency must resolve to real reference.locales/reference.currencies rows (FK enforced). Main path:

  1. Caller submits new name/default_locale/default_currency.
  2. Command validates the permission, then UPDATE organization.organizations SET name = ..., default_locale = ..., default_currency = .... Adversarial / edge cases:
  • Caller attempts to change code — rejected at the API layer; the column has no dedicated "rename code" case in this catalog (flagged as an open product question in the final report, not assumed here).
  • Caller lacks org-wide scope (e.g., only has a business-unit-scoped role) — refused by the permission check, not by RLS (RLS only isolates the tenant, not the action).
  • Unknown default_locale/default_currency code — refused by the FK, not a silent fallback. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — PATCH /organizationCommand—Organizationorganization.organizationsNone: runtime_grants.sql already grants ninaku_runtime SELECT, UPDATE on organization.organizations, and organizations_update_policy confines the update to the selected active organization

O6 — Suspend, reactivate, or archive organization ​

Actor: Ninaku platform staff (control plane) only. Owner decision (2026-09-30): the tenant never suspends, reactivates or archives its own organization; a reason is always required; archival is one-way; archival is destructive, so it also requires a live elevation (step-up) and an Idempotency-Key. The tenant-side suspension routes this catalog first proposed are superseded by SP17 and are not built. Intent: move the organization between active, suspended, and archived without ever deleting it. Preconditions: organization exists; for reactivation, the organization must be able to satisfy D21 immediately upon becoming active again. Invariants protected: D21's assert_administration_preserved — reactivating a suspended organization that never recovered administration is one of the paths D21 explicitly names as blocked; a suspended/archived organization keeps its history but is never treated as operable regardless of its administrator state. Main path:

  1. Caller requests a status transition with a reason.
  2. UPDATE organization.organizations SET status = ... inside a transaction; the deferred constraint trigger evaluates assert_administration_preserved at commit.
  3. Reactivation additionally requires (per D21) that an active organization-wide administrator already exists, or the same transaction restores one — otherwise commit fails with 23514 · identity_last_administrator_required. Adversarial / edge cases:
  • Reactivating a suspended organization that lost its last administrator while suspended — refused at commit, not silently allowed because the organization "was already broken."
  • Suspending an organization that is mid-provisioning (no completed platform.organization_provisioning_requests row yet) — D21's guarantee only starts once provisioning is completed; suspending an unprovisioned row is a no-op with respect to the invariant, but should still be rejected as a state that shouldn't exist yet.
  • Archiving is one-way (owner decision, 2026-09-30): archived is terminal. Staff reactivation, staff payments, the lifecycle sweep and a direct UPDATE by any role are all refused or leave the organization archived (organizations_archived_terminal_trg, 23514 · organizations_archived_terminal).
  • Archiving an organization that is already archived, or mid-provisioning, is refused and leaves one denied audit record. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — staff only, in the Platform module: POST /platform-admin/organizations/{organizationId}/suspension and .../reactivation (SP17) and .../archival (elevation guarded, Idempotency-Key, { reasonText }). The tenant-side POST /organization/{suspension,reactivation,archival} are not builtCommand—Platform (control plane) command over a table the Organization domain owns; guarded by Identity's D21 triggerplatform.suspend_organization_as_staff, platform.reactivate_organization_as_staff, platform.archive_organization_as_staff, organization.organizations, organizations_archived_terminal_trg, transitively identity.assert_administration_preservedNone: the commands are SECURITY DEFINER functions gated by platform.is_active_staff_member() and executed by ninaku_runtime, which holds SELECT, UPDATE on organization.organizations under RLS and never writes status itself

Actor: a member with organization-wide organization.account.manage (decided: no narrower legal-entity permission exists yet; one can be added to the permission catalog later without changing the route). Intent: declare a new legal entity under this organization. Preconditions: tenant context established; code unique per organization (case-insensitive); incorporation_country resolves to a real reference.countries row. Invariants protected: organization_id is immutable once set (prevent_organization_reassignment); correction against the reviewer's proposal — organization.legal_entities carries no tax-id/RUC/NIF column in this schema; fiscal registration numbers live in a separate fiscal schema (introduced in 0400_cross_domain_contracts.sql), not on this table. The "changing a tax id may be a new legal entity" guardrail is real, but it attaches to whichever aggregate actually stores the fiscal registration — see O8 and the open question in the final report. Main path:

  1. Caller submits code, legal_name, optional trade_name, incorporation_country.
  2. INSERT INTO organization.legal_entities. Adversarial / edge cases:
  • Duplicate code (case-insensitive) within the same organization — refused by the unique index.
  • Unknown incorporation_country — refused by the FK. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /legal-entitiesCommand—Organizationorganization.legal_entitiesNone: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.legal_entities and no DELETE, and legal_entities_tenant_policy confines every read and write to the selected active organization. Migration 0038 only seeds the audit vocabulary (organization.legal_entity.registered, organization.legal_entity.updated, resource kind organization.legal_entity)

Actor: same as O7. Intent: rename a legal entity or change its trade name/status. Preconditions: legal entity belongs to the caller's tenant (RLS-scoped). Invariants protected: code and incorporation_country are treated as identity-defining and are not edited here — a change of jurisdiction or code means registering a new legal entity (O7) and retiring the old one (O22), not mutating this row in place. This preserves, without a tax-id column, the same spirit the reviewer's rule intended: identity-defining fields don't get silently rewritten. Main path:

  1. Caller submits legal_name and/or trade_name.
  2. UPDATE organization.legal_entities SET legal_name = ..., trade_name = .... Adversarial / edge cases:
  • Attempt to change code or incorporation_country — rejected at the API layer, not just left unmentioned.
  • Legal entity belongs to another organization — RLS makes the row invisible; the update affects zero rows, answered as a 404, not a 403 (never confirm existence across tenants). Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — PATCH /legal-entities/{id}Command—Organizationorganization.legal_entitiesNone: the grants and the policy of O7 already cover the update; the row is read FOR UPDATE under RLS, so another organization's legal entity is invisible and answered 404

4. Business unit ​

Correction against the reviewer's proposal: organization.business_units has no parent_id and no hierarchy at all — it is a flat list per organization. The hierarchy/cycle concern the reviewer expected here belongs to Outlet instead (see O18); it does not apply to Business Unit.

O9 — Register business unit ​

Actor: a member with organization.account.manage. Intent: create an operative/administrative subdivision of the organization. Preconditions: tenant context established; code matches ^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$ and is unique per organization (case-insensitive). Invariants protected: flat structure — no cycle to defend, because there is no parent column to misuse. Main path: submit code/name → INSERT INTO organization.business_units. Optional verticalCodes (D1, revision 109): 0 to 20 distinct codes matching ^[a-z][a-z0-9_]{1,79}$. After the insert the use case locks the new unit (BusinessUnitRepositoryPort.lock, the K10c precondition) and calls BusinessUnitVerticalsContract.activate once per code, in the same tenant transaction, so the verticals exist before the unit commits. The registration record comes first, then one organization.business_unit.vertical_activated record per vertical (the same record O11 writes). An unavailable outcome refuses the whole registration with 422 · organization.vertical_unavailable (the code of O11) and rolls it back; last_active_vertical cannot happen on an activation. What the new unit sells (D1 as built): Catalog's deferred trigger business_units_default_assortment_trg stores the unit's assortment when the registration commits: all for the first unit of the organization or when each of its active verticals already runs in another unit, none otherwise (also for a unit registered without verticals). It is stored once; a vertical turned on later does not change it. Organization never calls Catalog: the choice is Catalog's (CATALOG_OPTIONS_V2.md §18), and a client that wants another mode sends PUT /catalog/business-units/{id}/assortment right after registering; between the two requests the stored default applies. Adversarial / edge cases:

  • Duplicate code within the organization — refused.
  • Code violating the character-class check — refused.
  • A repeated, malformed or 21st vertical code — 400.
  • An unknown, deprecated or retired vertical — 422, nothing stored. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /business-unitsCommand—Organizationorganization.business_unitsNone: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.business_units and no DELETE, and business_units_tenant_policy confines every read and write to the selected active organization. Migration 0039 only seeds the audit vocabulary (organization.business_unit.registered, organization.business_unit.updated, resource kind organization.business_unit). Registering a business unit needs no vertical; the optional verticalCodes activates them through BusinessUnitVerticalsContract (Platform owns the rows, O11) in the same transaction. Catalog's deferred trigger on organization.business_units (migration 0099) stores the unit's default assortment at commit

O10 — Update business unit profile ​

Actor: same as O9. Intent: rename a business unit or change its status (status changes are handled generically by O22). Main path: submit name → UPDATE organization.business_units. Adversarial / edge cases: foreign-tenant business unit — invisible via RLS, answers 404. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — PATCH /business-units/{id}CommandStatus transitions merged into O22Organizationorganization.business_unitsNone: the grants and the policy of O9 already cover the update; the row is read FOR UPDATE under RLS, so another organization's business unit is invisible and answered 404. Only the name changes here; the code and the status do not

O11 — Activate or deactivate a vertical for a business unit ​

Revised for J1 (D30). Which roles an invitation may offer depends on the verticals active in the business units of the chosen scope, so O11 ships before J1-2 (slice J1-O11). Without it, only the first business unit of an organization has verticals (provisioning activates them, J1 finding 12) and a second business unit, registered through O9, could never receive vertical roles. The fallback (offer the roles of every vertical active anywhere in the organization) was rejected: it lets an owner offer a waiter scoped to a retail-only store, and the rule would have to change, with open invitations already built on the loose one, the day O11 ships.

Actor: a member holding organization.account.manage organization-wide (the same check every Organization command uses). A plan-driven activation is not built; it waits for a plan that actually restricts verticals. Intent: turn Restaurant or Retail capabilities on or off for one business unit, independent of the other business units in the same organization. Preconditions: live session, tenant context (O3); the vertical exists and is active in platform.verticals; the business unit belongs to the tenant and is active. Invariants protected: activation is per (business_unit_id, vertical_code) (business_unit_verticals_unit_vertical_uq), not per organization — two business units in the same organization can run different vertical mixes (ARCHITECTURE §13–14). An active row needs an active organization, business unit and vertical (platform_business_unit_vertical_active_context); organization_id, business_unit_id, vertical_code and enabled_at are immutable (platform_business_unit_vertical_immutable); business_unit_verticals_state_chk ties disabled_at to status. Main path:

  1. POST /business-units/{businessUnitId}/verticals/{verticalCode}/activation: no row → INSERT (status = 'active'), answer 201; an inactive row → UPDATE status = 'active', disabled_at = NULL (enabled_at stays the first activation: it is immutable, so the reactivation time lives in the audit record), answer 200; an active row → 200, nothing written.
  2. POST /business-units/{businessUnitId}/verticals/{verticalCode}/deactivation: an active row → UPDATE status = 'inactive', disabled_at = now(), answer 200; inactive or no row → 200, nothing written.
  3. Tenant audit record in the same transaction, only for a real change: organization.business_unit.vertical_activated / organization.business_unit.vertical_deactivated (new action types, migration 0056), resource kind organization.business_unit (exists). As built, the one change carries the vertical in its attribute code, organization.business_unit.vertical.<verticalCode>, with the status as before (absent for a first activation) and after (active or inactive).
  4. Deactivation does not touch role assignments or open invitations of that vertical's roles (D30): assignments stay labels, the vertical's modules check the vertical per command (platform.assert_operation_allowed takes p_vertical_code), and an open invitation is refused at acceptance with 422 · identity.invitation_role_vertical_inactive. Adversarial / edge cases:
PathAnswer
No session / foreign tenant401 · identity.session_required / 403 · tenant.context_rejected
Member without organization.account.manage organization-wide403 · organization.action_forbidden, audited as denied
Unknown business unit or another tenant's404 · organization.structure_not_found (RLS makes them the same)
Unknown vertical code, or one deprecated or retired422 · organization.vertical_unavailable (new; backstops business_unit_verticals_vertical_fk, platform_business_unit_vertical_active_context); deactivating an existing active row of a deprecated vertical still works
Inactive business unit422 · organization.vertical_unavailable
Malformed code (not ^[a-z][a-z0-9_]{1,79}$) or business unit id400 · foundation.validation_failed
Deactivating the organization's last active business-unit vertical409 · organization.last_vertical_required (new): provisioning refuses an organization without one (platform_provisioning_configuration_required), and an organization with none could offer only neutral roles. "Last" is per organization, counted over the active rows of its active business units: a unit may lose its only vertical while another unit keeps one, and an inactive row, another organization's rows and an active row inside an inactive unit never count
Activation repeated, or two activations raced200, one row: as built, the row lock on the business unit serialises the two requests, so the second one reads the first one's row and answers it without writing; the unique constraint is only a backstop and is not reached
Deactivating an inactive or never-activated pair, or an unknown code200 with status = 'inactive'; nothing is written or audited (as built)
Inactive or archived business unit on deactivation422 · organization.vertical_unavailable, like activation (as built)

Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /business-units/{businessUnitId}/verticals/{verticalCode}/activation, .../deactivationCommand—Organization: it owns the business unit and the route, and it already writes the first activation through provisioning; the table lives in the platform schemaplatform.business_unit_verticals, platform.verticals, organization.business_units, audit.recordsNone for the tables: runtime_grants.sql grants SELECT on platform.verticals and SELECT, INSERT, UPDATE on platform.business_unit_verticals, under business_unit_verticals_tenant_policy (J1 finding 13). New: the two audit action types. The last-vertical rule needs a per-organization lock (SELECT ... FOR UPDATE on the organization's active rows) so two concurrent deactivations cannot both pass. As built: no SQL function was needed. The repository in infrastructure/business-units/business-unit-vertical.repository.ts reads platform.verticals and reads and writes platform.business_unit_verticals directly with the existing grants and business_unit_verticals_tenant_policy (the module-boundary rules constrain TypeScript imports, not SQL, and provisioning already writes the same table), maps the trigger's platform_business_unit_vertical_active_context and the foreign key business_unit_verticals_vertical_fk to organization.vertical_unavailable by constraint name, and locks, in this order, the business unit row and then the organization's active rows of active units ORDER BY id FOR UPDATE OF (a fixed order, so concurrent deactivations cannot deadlock each other). Migration 0056 (revision 66) only seeds the two audit action types

5. Brand ​

O12 — Register brand ​

Actor: a member with organization.account.manage. Intent: declare a commercial identity under the organization. Preconditions: code unique per organization (case-insensitive). Main path: submit code/name → INSERT INTO organization.brands. Adversarial / edge cases: duplicate code — refused. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /brandsCommand—Organizationorganization.brandsNone: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.brands and no DELETE, and brands_tenant_policy confines every read and write to the selected active organization. Migration 0039 only seeds the audit vocabulary (organization.brand.registered, organization.brand.updated, resource kind organization.brand)

O13 — Update brand profile ​

Actor: same as O12. Intent: rename a brand. Adversarial / edge cases: foreign-tenant brand — invisible via RLS, 404. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — PATCH /brands/{id}CommandStatus transitions merged into O22Organizationorganization.brandsNone: the grants and the policy of O12 already cover the update; the row is read FOR UPDATE under RLS, so another organization's brand is invisible and answered 404. Only the name changes here; the code and the status do not

6. Site ​

O14 — Register site ​

Actor: a member with organization.account.manage. Intent: declare a physical or logical location group. Preconditions: code unique per organization; country_code resolves; timezone is a real IANA zone; both coordinates present or both absent. Invariants protected: the sites_validate_timezone_trg trigger checks the timezone against pg_timezone_names at write time — an invalid zone never reaches the row. Main path: submit fields → INSERT INTO organization.sites. Adversarial / edge cases:

  • Invalid IANA timezone string — refused by the trigger, which names the constraint sites_timezone_known; the repository reports an invalid timezone only for that name.
  • Latitude without longitude (or vice versa) — refused by the check constraint.
  • Latitude/longitude out of range — refused by the check constraint. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /sitesCommand—Organizationorganization.sitesNone: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.sites and no DELETE, and sites_tenant_policy confines every read and write to the active organization; migration 0040 only seeds the audit vocabulary

O15 — Update site profile ​

Actor: same as O14. Intent: correct address, timezone, or coordinates. Adversarial / edge cases: same timezone/coordinate guards as O14 apply on update, not just insert (trigger fires on both). Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — PATCH /sites/{id}CommandStatus transitions merged into O22Organizationorganization.sitesNone: the grants and the policy of O14 already cover the update; the row is read FOR UPDATE under RLS, so another organization's site is invisible (404)

7. Outlet ​

O16 — Open outlet ​

Actor: a member with organization.account.manage, scoped at least to the target business unit/site. Intent: stand up a point of sale/operation tied to one brand and one site, optionally nested under a parent outlet. Preconditions: brand_id and site_id belong to the same organization as the outlet (enforced by composite FKs, not just RLS); optional parent_outlet_id must belong to the same site_id and organization_id. Invariants protected: cross-tenant adversarial case — a foreign brand or foreign site cannot even be referenced: outlets_brand_organization_fk and outlets_site_organization_fk are composite FKs over (id, organization_id), so a brand_id/site_id from another tenant has no matching row to satisfy the FK at all, independent of RLS. Main path: submit code/name/brand_id/site_id/optional parent_outlet_id → INSERT INTO organization.outlets (fires outlets_hierarchy_trg when a parent is given). Adversarial / edge cases:

  • brand_id/site_id from another organization — refused by the FK, not by RLS (defense in depth; RLS would also hide the row, but the FK refuses the write outright).
  • parent_outlet_id pointing at an outlet on a different site_id — refused by the composite FK on (parent_outlet_id, site_id, organization_id).
  • parent_outlet_id = id (self-parent) — refused by outlets_not_self_parent_chk. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /outletsCommand—Organizationorganization.outletsNone: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.outlets and no DELETE, and outlets_tenant_policy confines every read and write to the active organization; migration 0063 only seeds the audit vocabulary (organization.outlet.opened, organization.outlet.updated, organization.outlet.reparented, resource kind organization.outlet)

As built (O16): body { code, name, brandId, siteId, parentOutletId?, businessUnitId?, legalEntityId? } (strict: any other field, such as the status or the organization, is 400), organization-wide organization.account.manage plus X-Organization-Id, 201 with { outletId, code, name, brandId, siteId, parentOutletId, status }. The code is the same bounded text as brands and sites (50 characters, unique per organization ignoring case). A code already taken is 409 organization.structure_code_taken. A brand, a site or a parent that does not exist, belongs to another organization, is inactive or (for the parent) sits on another site is 422 organization.structure_refused with one detail per kind of element and the same answer for unknown and foreign. As built, the database answers that before the composite foreign key: the activation trigger (organization_outlet_activation_brand and _site) fires first, so a foreign brand and an unknown one are the same refusal; the composite keys stay as the second guard. A self-parent cannot be opened, because the server generates the identifier. There is no plan limit for outlets and no vertical rule in the schema today, so none is invented. Audit: one organization.outlet.opened record with the code, name, brand and site (and the parent, the business unit and the legal entity when there are) as after values; a denied attempt is audited against the organization. With businessUnitId and legalEntityId (O20 and O21, decision D4) the same transaction also creates the assignments from today at the site of the outlet; an unknown or foreign business unit or legal entity is 404 organization.structure_not_found, the same answer as on the assign routes, because an assignment target is looked up as a resource (the composite foreign key failure maps to it), while an inactive one is 422 organization.structure_refused with its own detail; nothing is stored in either case, not even the outlet. The brand and the site are structural parents and keep 422 for unknown and foreign.

O17 — Update outlet profile ​

Actor: same as O16. Intent: rename an outlet. Invariants protected: brand_id/site_id are not edited by this case, and the owner decided they never change: the database refuses it (organization_outlet_site_brand_immutable, migration 0069), so they need no effective-dated history like the O20/O21 assignments. Adversarial / edge cases: attempt to change brand_id/site_id through this route — rejected at the API layer. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — PATCH /outlets/{id}CommandStatus transitions merged into O22Organizationorganization.outletsNone: the grants and the policy of O16 already cover the update; the row is read FOR NO KEY UPDATE under RLS, so another organization's outlet is invisible (404)

As built (O17): body { name } only and strict, so brandId, siteId, parentOutletId, code, status and any other field are 400; the decision on brand and site immutability stays as the owner left it. 200 with the outlet; a name equal to the stored one answers 200 with no write and no record. No ETag or If-Match: no structure route has them (D24 write side waits for a structure read). Unknown or foreign outlet: 404 organization.structure_not_found. Audit: organization.outlet.updated with the name before and after.

O18 — Re-parent an outlet ​

Actor: same as O16. Intent: move an outlet under a different parent outlet (or detach it to the top level) within the same site. Preconditions: new parent (if any) belongs to the same site_id and organization_id. Invariants protected: schema-enforced, not application-level — the outlets_hierarchy_trg trigger (a) takes a per-organization advisory transaction lock so two concurrent re-parents on the same tree serialize, (b) walks organization.outlet_ancestors() to refuse any move that would create a cycle, and (c) refuses activating an outlet whose ancestor chain contains an inactive outlet. Main path: submit new parent_outlet_id → UPDATE organization.outlets (fires the same trigger as O16). Adversarial / edge cases:

  • Move that would make an outlet its own ancestor (direct or transitive) — refused with 23514 · organization_outlet_acyclic.
  • Activating (or leaving active) an outlet whose new parent chain has an inactive outlet — refused with 23514 · organization_outlet_parent_active.
  • Two concurrent re-parent requests on the same organization's tree — the advisory lock (organization.outlet-tree:<org_id>) serializes them; the second waits, then re-evaluates against the first's result. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /outlets/{id}/re-parenting (task-oriented, not a raw field PATCH, because it carries cycle/ancestor semantics)Command—Organizationorganization.outlets, organization.outlet_ancestors()None: the table grants of O16 cover the update and outlet_ancestors() is callable by the runtime role from the trigger

As built (O18): body { parentOutletId: uuid | null } (required, strict); 200 with the outlet. The use case locks the outlet row, answers 200 with no write and no record when the parent is the one it already has, and otherwise updates it; the outlets_hierarchy_trg trigger does the rest, and the repository maps its constraint names only in the infrastructure layer: a cycle (organization_outlet_acyclic, which also covers moving an outlet under itself because the trigger fires before outlets_not_self_parent_chk) and an inactive ancestor (organization_outlet_parent_active) are 422 organization.structure_refused with their own detail; a parent that is unknown, foreign or on another site is the same 422 (composite foreign key). Two moves in one organization never run together: the trigger takes the per-organization advisory lock, so the second waits and is judged against the committed result (proven in the database with two sessions, in the repository with two transactions and over HTTP with opposite moves). The outlet row is locked FOR NO KEY UPDATE, not FOR UPDATE: the composite parent foreign key takes FOR KEY SHARE on the new parent, which FOR UPDATE blocks, so a move holding the advisory lock waited for a parent row locked by a move waiting for that same advisory lock, and PostgreSQL aborted one of them as a deadlock (40P01); three in a row exhausted the retries and answered 503. No use case deletes an outlet or changes its key columns, so the weaker lock still serializes every writer of the row. The inactive ancestor is reachable over HTTP since O22 (POST /outlets/{outletId}/deactivation). Audit: organization.outlet.reparented with the parent before and after, with no value when there is no parent.


8. Sales channel ​

O19 — Register sales channel ​

Actor: a member with organization.account.manage. Intent: declare a channel (pos/storefront/kiosk/marketplace/api/ phone/manual/import/other) through which sales occur. Note: the reviewer's list did not name this table; it is added here because 0020_organization.sql owns it as Core, alongside brand/site/outlet. It is kept deliberately light — no consumer module exists yet — and its implementation priority is an open question in the final report, not a commitment to build it early. Main path: submit code/name/channel_kind → INSERT INTO organization.sales_channels. Adversarial / edge cases: unknown channel_kind — refused by the check constraint; duplicate code — refused. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /sales-channelsCommand—Organizationorganization.sales_channelsNo grant to ninaku_runtime today

9. Effective assignments ​

Actor: a member with organization.account.manage. Intent: declare which legal entity an outlet trades under, from a given date, without erasing who it traded under before. Preconditions: outlet and legal entity belong to the caller's tenant and are active. Invariants protected: append-only history — protect_outlet_legal_entity_assignment refuses any UPDATE that changes outlet_id, legal_entity_id, or valid_from on an existing row (only valid_until/status may close it); no overlapping active periods for the same outlet — a GiST EXCLUDE constraint on (organization_id, outlet_id, daterange(valid_from, valid_until)) filtered to status = 'active'; no assignment to a legal entity that is not active — outlet_legal_entity_target_trg (organization.validate_outlet_assignment_target, migration 0069). Main path:

  1. The caller submits the legal entity and, optionally, validFrom (default: today) and validUntil.
  2. The use case first locks the organization-wide structure version row (admitStructureWrite, the same first lock as every structure write), which is what serialises two assignments of one outlet, and of any two outlets of the organization; it then takes the outlet tree advisory lock and the outlet row (the same locks as O17 and O18), reads the active periods that reach validFrom, closes the one that covers it and inserts the new row, with the audit record, in one transaction. Adversarial / edge cases:
  • Two concurrent "assign from today" commands for the same outlet — the structure version row lock, taken before the outlet row lock, serializes them: the first creates the row, the others find the same target current and answer 200 (or, with another target, replace it the same day). If the lock were bypassed, the GiST exclusion still lets exactly one win and the other fails with 23P01 (proven with two connections).
  • valid_until <= valid_from — refused by the period check constraint, and by the request schema before it reaches the database.
  • Legal entity from another organization — refused by the composite FK, and answered 404 before it by the same read the use case does under RLS. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /outlets/{id}/legal-entity-assignmentsCommand—Organizationorganization.outlet_legal_entity_assignmentsNone: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on both assignment tables (migration 0017) and no DELETE; migration 0069 only seeds the audit vocabulary and adds the activation guard

As built (O20): body { legalEntityId, validFrom?, validUntil? } (strict, dates are YYYY-MM-DD), organization-wide organization.account.manage plus X-Organization-Id. Answers:

StatusWhen
201 { id, outletId, legalEntityId, validFrom, validUntil, status }a row was created
200 same bodythe same legal entity is already current from that date: no write, no record; or, with a different validUntil, its end date moved (D5) with one record
400 foundation.validation_failedmalformed body or date, unknown field, validUntil <= validFrom when both are sent
401, 403 tenant.context_rejectedno session, or the organization header is missing or not the caller's
403 organization.action_forbiddenthe caller lacks the organization-wide permission (a member scoped to one business unit included); one denied record against the outlet
404 organization.structure_not_foundunknown or foreign outlet or legal entity, with one and the same answer
409 organization.assignment_conflict (new)another active period of the outlet starts after validFrom, or a moved end would reach past its start (D5)
422 organization.structure_refusedthe outlet or the legal entity is not active, validFrom is before today, or validUntil is not after a validFrom that defaulted to today

Decisions: D1 when a new assignment replaces the current one the previous row stays active with its period closed (valid_until = the new valid_from), so effective_legal_entity still answers for past dates; a row replaced before it took effect (it starts on the same day as the new one) becomes superseded. D2 "today" is the date in the timezone of the site of the outlet, computed in the repository (SELECT (now() AT TIME ZONE sites.timezone)::date); since migration 0070 identity.effective_permission and identity.role_offerable_at_scope pass the same site date to the resolvers effective_legal_entity and effective_business_unit; platform.assert_operation_allowed still passes the current_date of the database, which waits for the first vertical command that calls it. D3 an assignment cannot start in the past; validFrom defaults to today. D4 POST /outlets (O16) accepts the optional businessUnitId and legalEntityId and creates both assignments from today in the same transaction. An assignment target is looked up as a resource, so an unknown or foreign business unit or legal entity answers 404 organization.structure_not_found there exactly as on the assign routes, and an inactive one 422 organization.structure_refused; the brand and the site of the new outlet are its structural parents and keep 422 organization.structure_refused for unknown, foreign and inactive alike. Nothing is stored in any of these cases. Where the specification was silent: a row that starts on the same day as the new one is replaced, not a conflict (only a row that starts later is); the same target current from the date answers 200 even when a later row waits; validUntil on a new row closes the outlet's assignment at that date, it does not restore the previous one: a bounded assignment [d1, d2) closes the current one at d1 and leaves a gap from d2 on, and a gap is never filled (proven over HTTP: the resolver answers no target on d2). D5 (owner-delegated) repeating the same target with a different validUntil moves the end of the row that covers validFrom instead of answering 200 unchanged: a date sets or moves it, null clears it, and omitting validUntil leaves it as stored, which keeps the old idempotent repeat; the answer is 200 with the updated body and one audit record under the same action with the change organization.outlet.valid_until (before and after, no value when open). An identical end writes and records nothing; an end that is not after the start is refused like a new period (422 when validFrom defaulted, 400 when both are sent); an end that reaches past the start of a later assignment, or null while one waits, is 409 organization.assignment_conflict. Two concurrent moves serialise on the structure version row and leave one consistent row whose end is the after value of the last record. Audit: organization.outlet.legal_entity_assigned with the legal entity before (the one replaced, or no value) and after, and organization.outlet.valid_from with the date; a denied attempt is audited against the outlet. The history is read through GET /outlets/{outletId}/legal-entity-assignments (O24).

O21 — Assign outlet to business unit, effective from a date ​

Identical shape to O20, over organization.outlet_business_unit_assignments. Actor / Intent / Invariants: same as O20, substituting business unit for legal entity (outlet_business_unit_target_trg guards the activation). Adversarial / edge cases: same three rows as O20, substituting business unit for legal entity. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /outlets/{id}/business-unit-assignmentsCommandKept distinct from O20 — independent history tables, independent GiST constraintsOrganizationorganization.outlet_business_unit_assignmentsNone, as for O20

As built (O21): body { businessUnitId, validFrom?, validUntil? }, the same answers, codes and decisions D1 to D4 as O20, with organization.outlet.business_unit_assigned and organization.outlet.business_unit_id in the audit record. A vertical role (waiter, kitchen) can be offered or assigned at the scope of an outlet only once the outlet has a business unit that runs the vertical: an outlet with no assignment is still refused with 422 identity.invitation_role_vertical_inactive or 422 identity.role_assignment_vertical_inactive, and the same request answers 201 after this route (or businessUnitId in POST /outlets) gave it the business unit. Brand and site are immutable (O17): migration 0069 makes the database refuse any change of site_id or brand_id of an outlet (organization_outlet_site_brand_immutable). What the outlet sells (K16): an outlet sells what the assortment of the business unit that operates it today lists, minus the items the outlet hides (catalog.outlet_hidden_items, PUT /catalog/outlets/{id}/hidden-items, CATALOG_USE_CASES.md C30). Organization never reads or writes those rows: when a new business-unit assignment starts, they stay and apply to the assortment of the new unit, and a hidden item the new unit does not list is simply irrelevant.


10. Lifecycle and structure reads ​

O22 — Deactivate or reactivate a structural entity ​

Actor: a member with organization.account.manage. Intent: move an organization/business unit/legal entity/brand/site/ outlet/sales channel between its lifecycle states — active is always reachable again except where noted; nothing is ever deleted.

Invariants protected (per-aggregate lifecycle table — this replaces the reviewer's single generic case, because each aggregate's lifecycle is genuinely different):

AggregateStatesReversible?Notes
organization.organizationsactive / suspended / archivedsuspended → active, gated by D21archived treated as terminal in this catalog
organization.business_unitsactive / inactive / archivedinactive → activevalidate_child_activation blocks activating a child whose parent is not active
organization.legal_entitiesactive / inactive / archivedinactive → active—
organization.brandsactive / inactive / archivedinactive → active—
organization.sitesactive / inactive / archivedinactive → active—
organization.outletsactive / inactive / archivedinactive → activeadditionally gated by outlets_hierarchy_trg's active-ancestor rule (O18)
organization.sales_channelsactive / inactive / archivedinactive → active—
identity.membershipsactive / suspended / endedsuspended → active; ended is terminal (a returning person gets a new membership row)different vocabulary from the rest — flagged so nobody assumes it matches
identity.role_assignmentsno status column at allnot applicable — revocation is a hard DELETE, not a soft transitionthe one aggregate in this catalog that breaks "never delete"; see O28
identity.invitationspending / accepted / revokednone — both terminal states are finala still-pending row may be deleted (to allow reissue, O33); accepted/revoked rows may never be deleted

Main path: submit a target status with a reason → UPDATE the aggregate's status column; deferred triggers (D21 on the organization side, validate_child_activation/outlets_hierarchy_trg on the structural side) evaluate at commit. Adversarial / edge cases:

  • Activating a business unit/outlet whose parent is not active — refused.
  • Deactivating the organization's only active administrator's business unit scope in a way that would leave the organization without an admin — refused (D21, see O6/O28). Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /{aggregate}/{id}/deactivation, .../reactivation (one task-oriented pair per aggregate, not raw PATCH status)CommandAbsorbs the reviewer's O18Organization (structural aggregates); Identity (memberships)see table abovesee per-case rows above

As built (O22, lot B, migration 0070, revision 80): ten routes, one pair per aggregate: POST /sites/{siteId}/deactivation and /reactivation, and the same for /brands/{brandId}, /legal-entities/{legalEntityId}, /business-units/{businessUnitId} and /outlets/{outletId}. No body; organization-wide organization.account.manage plus X-Organization-Id; the organization itself and the memberships keep their own staff and O29 routes.

StatusWhen
200 with the body of the aggregate (the same shape as its register and update routes)the status changed; one organization.<kind>.deactivated or .reactivated record with organization.<kind>.status before and after
200 same bodythe element already has the status asked for: no write, no record
400 foundation.validation_failedthe identifier is not a UUID
401, 403 tenant.context_rejectedno session, or the organization header is missing or not the caller's
403 organization.action_forbiddenthe caller lacks the organization-wide permission (a cashier, a member scoped to one business unit); one denied record against the element
404 organization.structure_not_foundunknown or foreign, one and the same answer
409 organization.structure_in_use (new)deactivation blocked by active dependents: a site or a brand with active outlets, an outlet with active child outlets, a legal entity or a business unit assigned to an active outlet today or from a later date
409 organization.last_vertical_requireddeactivating the business unit that holds the last active vertical of the organization
422 organization.structure_refusedreactivating an outlet under an inactive site, brand or ancestor outlet (as O18), reactivating an outlet assigned (today or from a later date) to an inactive business unit or legal entity, or changing an archived row in either direction

The database decides, and the repository maps constraint names only in the infrastructure layer: organization.guard_structure_deactivation is a BEFORE UPDATE OF status trigger on the five tables (23514 organization_<kind>_deactivation_in_use, organization_business_unit_last_vertical); reactivation reuses validate_child_activation and outlets_hierarchy_trg; for an outlet, validate_child_activation also locks FOR SHARE the business units and legal entities it is assigned to today or from a later date (the same assignments the deactivation guard counts) and refuses one that is not active (23514 organization_outlet_activation_business_unit and organization_outlet_activation_legal_entity), so closing the outlet and then its business unit can no longer be followed by reopening the outlet alone. The use case locks the row first (FOR UPDATE, FOR NO KEY UPDATE for an outlet), so a site deactivation and an outlet opening on it are serialised by the site row (the opening takes FOR SHARE): whichever commits first wins and the other is refused (proven with two connections). The outlet guard also takes the per-organization outlet-tree lock, so a parent cannot close while a child opens. The last-vertical guard locks the active vertical rows of the organization in id order, the same order as O11, so the two cannot both pass; the guard and the O11 turn-off also take one per-organization advisory lock (organization.verticals:) before reading the verticals, so a turn-off racing a business unit deactivation waits for it and then no longer counts the verticals of the closed unit (proven with two connections; locking the business unit rows instead deadlocks two turn-offs that each hold their own unit).

Decisions: D5 the sales channel lifecycle is proven at the database layer only, without a route (deferred with O19). D6 deactivating a parent with active dependents is refused, never cascaded; closing a branch is bottom-up and reopening it top-down. D8 an archived row is refused in both directions (there is no archive route). Closing an outlet leaves its assignments to a legal entity and a business unit untouched, so reopening it restores it as it was.

Inactive scopes (owner decision a). A role assignment scoped to a site, brand, business unit, legal entity or outlet that is inactive is kept as history but grants nothing through it, in every form (direct, descendants, effective assignment), and neither does a scope below the organization at a target outlet that is inactive. An organization-wide role keeps granting at an inactive outlet, and the administrator scope is organization-wide, so it is never affected. identity.effective_permission keeps its shape and join_collapse_limit = 1; the scope branch is evaluated by the new invoker function identity.access_scope_covers_outlet, which is only planned and run for a scope below the organization, so the organization-wide grant path does not pay for it. After a refusal, and only then, identity.permission_scope_unavailable answers whether the same membership would hold the permission if the scoped entities were active; the identity contract PermissionCheckContract.decide returns granted, denied or scope_unavailable from the two, and a member who never held a covering scope always gets denied. The public answer for it is meant to be 403 authorization.scope_unavailable with the detail "The place you are assigned to is not available. Contact your administrator."; no route checks a permission at an outlet yet (every route checks organization.account.manage organization-wide, the only permission seeded), so that answer has no route to travel on and is not built: the decision is proven at the contract and database layers and waits for the first outlet-scoped route. Inactive targets are not offered: identity.role_offerable_at_scope refuses a target that exists and is not active, so GET /offerable-roles answers an empty list for it, and a new invitation (422 identity.invitation_refused) or role assignment (422 identity.role_assignment_refused) to an inactive target is refused as before; the database also refuses an access scope on an inactive target for every role, with or without a vertical (23514 identity_role_assignment_scope_inactive, raised by identity.validate_access_scope_vertical).

Site date (an intended change of behaviour). The same migration resolves the effective business unit and legal entity of effective_permission and role_offerable_at_scope on the date of the time zone of the site of the outlet, (now() AT TIME ZONE site.timezone)::date, instead of the database current_date (UTC on Railway), like the O20 and O21 assignments (D2). Around midnight an assignment that starts or ends "today" at the site therefore grants or stops granting by the site's calendar, not the UTC one. The old-versus-new differential reports this class apart from the inactive-scope class: with a far-east (Pacific/Kiritimati) and a far-west (Pacific/Pago_Pago) site, at least one of them is on a different date than UTC at any instant, and every difference found is the site-date rule. A site cannot store a timezone PostgreSQL does not know (sites_timezone_known), so the site date cannot fail on a stored value.

O23 — Read organization structure ​

Actor: any member of the organization. Intent: see the whole structure — business units, legal entities, brands, sites, and the outlet tree with each outlet's currently-effective legal entity and business unit — in one composed read. Main path: one read model joins organization.business_units, legal_entities, brands, sites, outlets (with organization.outlet_ancestors() for the tree shape), and organization.effective_legal_entity()/effective_business_unit() for each outlet's current assignment. Adversarial / edge cases: an outlet with no current assignment (all prior rows superseded, no active row) — surfaced as null, not hidden or defaulted. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — GET /organization/structureRead model—Organizationall organization.* tables above, plus the two resolver functionsNo grant to ninaku_runtime on any of them today

O24 — Read effective assignment history ​

Actor: any member with visibility into the target outlet (org-wide or scoped). Intent: see every legal-entity/business-unit assignment an outlet has ever had, not just the current one. Main path: read all rows (active and superseded) from organization.outlet_legal_entity_assignments/outlet_business_unit_assignments for the outlet, ordered by valid_from. Adversarial / edge cases: an outlet with a gap (a period with no active assignment of either kind) — the read surfaces the gap; it does not interpolate. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — GET /outlets/{id}/legal-entity-assignments/history, .../business-unit-assignments/historyRead model—Organizationorganization.outlet_legal_entity_assignments, organization.outlet_business_unit_assignmentsNo grant to ninaku_runtime today

As built (O23 and O24, migration 0072, baseline revision 82). The decisions the owner delegated: D10 the ETag of the structure comes from a version row per organization, organization.structure_versions(organization_id, version), moved forward by statement triggers, not from a content hash, so If-Match costs one locked row read; D11 every structure read needs organization-wide organization.account.manage, like every Organization route; D12 the composed read stops at 500 outlets and answers meta.truncatedtrue, the paged route serves the rest, and no plan limit is added.

  • GET /organization/structure?status=active|all answers { businessUnits, legalEntities, brands, sites, outlets } in one SQL statement (JSON aggregation), each element with the body its own route already answers, and the outlets as a flat list in opening order with parentOutletId, currentBusinessUnitId and currentLegalEntityId (null when nothing is assigned today; a gap is never filled in). Today is the date of the time zone of the site of the outlet, as in O20 and O21; the statement applies the rule of organization.effective_business_unit and effective_legal_entity through two lateral joins instead of calling them per outlet, which took the read of a 200-outlet organization from about 22 ms to about 2 ms in EXPLAIN (ANALYZE, BUFFERS). status=active, the default, leaves out every inactive or archived element so a selector never offers one; status=all lists them for administration screens. meta.truncated says whether more than 500 outlets exist.
  • GET /organization answers the profile, the same body PATCH /organization answers.
  • Both reads answer Cache-Control: private, no-cache and a strong ETag "<organizationId>:<version>:<representation>" (structure-active, structure-all, profile), so the two filters never share a tag and a tag of one organization never matches another; a matching If-None-Match answers 304 with no body. Reads record nothing. The structure tag carries one more segment, a SHA-256 of the composed outlets with their current business unit and legal entity: those ids depend on the site date, which moves at midnight at the site or when a valid_until passes with no write and no new version, so without it an old If-None-Match kept answering 304 with stale current ids. Proven over HTTP for both filters by moving a site from Pacific/Pago_Pago to Pacific/Kiritimati with triggers disabled (an SQL fixture standing for the clock passing midnight, the version unchanged): the old tag stops matching, the new one matches, and nothing else moves the tag. The profile tag does not depend on dates and has no such segment.
  • Declared costs of D10: one version for the whole organization means two administrators editing unrelated elements can get 412 from each other when both send If-Match, and the profile ETag moves on every structure write even when the profile did not change. Write responses do not return a new ETag, so a client reads again before its next conditional write.
  • GET /sites/{siteId}/outlets?after&before&limit pages every outlet of the site, whatever its status, newest first on (created_at DESC, id DESC) with the signed cursor of the other collections (bound to the route, the organization and the site), default 50, at most 100. The existing outlets_organization_site_idx serves it: EXPLAIN (ANALYZE, BUFFERS) over a site with 501 outlets reads 15 buffers in about a millisecond, so no index was added. The brief named the cursor parameter cursor; the route follows the repository convention after / before.
  • GET /outlets/{outletId}/legal-entity-assignments and .../business-unit-assignments answer the whole history of the outlet, active and superseded rows, ordered by valid_from, as a plain array of { id, targetId, validFrom, validUntil, status }, the latest 500 rows at most, still in ascending order, with meta.truncated true when older rows were left out (the statement selects by valid_from DESC with one row more than the cap and the response is ordered ascending). The /history suffix planned above was dropped: the plural collection already names the history, and its POST sibling creates one more row of it.
  • An unknown site or outlet and one of another organization are the same 404 organization.structure_not_found; a member without the permission is 403 organization.action_forbidden (not audited, reads record nothing).
  • If-Match on PATCH /organization and on every structure write (create, update, re-parenting, deactivation, reactivation and both assignments of sites, brands, legal entities, business units and outlets) is optional: an absent header still writes (D24); the ETag of any of the three representations of the current version passes, the structure tag whatever its assignment segment; anything else answers 412 foundation.precondition_failed and writes nothing. The check is one shared function, admitStructureWrite, called by every structure write after its permission check: it locks the version row FOR NO KEY UPDATE before any other structure lock, with or without If-Match, so every structure write of an organization takes the version row first and the outlet tree advisory lock, the vertical lock and the row locks of lots A and B after it. Every outlet write (rename, re-parenting, status change and both assignments) takes the exclusive organization.outlet-tree:<org_id> advisory lock in OutletRepository.lock before the outlet row lock, because organization.assert_outlet_operational (sales, cash and platform) takes the shared tree lock before its FOR SHARE on the outlet row: taking the row first and the tree lock later in the trigger deadlocked (40P01) with a sale on the outlet, proven with two sessions in database/tests/concurrency/outlet-lock-order-concurrency.ts; the trigger that moves the version at the end of the statement updates a row its own transaction already holds, so no structure write can wait on another in the opposite order. A write that changes nothing (the same values) does not move the version.
  • The version moves through organization.bump_structure_version, an invoker AFTER INSERT, UPDATE and DELETE statement trigger function with the transition table changed_rows on the seven structure tables (three triggers each, PostgreSQL refuses transition tables on a trigger with more than one event), once per statement and organization changed; and organization.track_organization_structure_version, a row trigger on organization.organizations that creates the row of a new organization and moves it when the code, name, locale or currency really change (staff status changes do not). The runtime may SELECT the table and UPDATE only its version column, under structure_versions_tenant_policy; existing organizations were backfilled with version 1. The table is justified (API and schema discipline) because no existing row changes on every structure write: the organization row would add a write lock on the tenant root to every structure write and conflict with the staff lifecycle.
  • GET /session/context adds organizationContext.selectedOrganization { id, code, name } (null when nothing is selected) next to selectedOrganizationId, read in the statement that already reads the roles, so the route keeps its 13 statements.

J1 — Bring my team in (journey) ​

Status: built and complete after slice J1-6. The cases it uses (O25, O28, O29, O30, O31, O32, O33) are revised below for this journey; the decisions it depends on are in J1 decisions and in DECISIONS.md D27 to D30 (D30: the system roles by vertical that an invitation offers).

Actors.

ActorWhoWhat they hold
OwnerThe person who provisioned the organization (O1)An active membership with the global organization_owner role, organization-wide scope, so organization.account.manage organization-wide
Invitee with an identityA future manager, waiter, kitchen, bar, cashier, sales_associate or stock_keeper who already has a Ninaku identityA verified, non-revoked identity.login_identifiers row for the invited email or phone, or none yet for that identifier
Invitee without an identityThe same people, never having used NinakuOnly the invitation message
WorkerThe ninaku_worker service (D26)EXECUTE on the security outbox claim/record/purge functions only

The story, in business steps. Two owners walk through it: Ana runs a restaurant (one business unit with the restaurant vertical active, chosen at provisioning); Bruno runs a clothing store (one business unit with retail active).

  1. Invite. Ana opens "Team", enters her manager's email, picks the scope (organization-wide, or one business unit, legal entity, brand, site or outlet) and then the role. The role list depends on the scope (D30): for her restaurant it offers manager, cashier, waiter, kitchen and bar, never organization_owner, sales_associate or stock_keeper. She invites the manager organization-wide, then a waiter and a kitchen cook by WhatsApp scoped to her one outlet, and a cashier by email. Bruno's list for his store offers manager, cashier, sales_associate and stock_keeper; he invites a sales_associate and a stock_keeper. (O30)
  2. Deliver. In the same transaction as the invitation row, one invitation message is enqueued in the Identity security outbox with the invitation token sealed. The worker sends it by email (SES) or WhatsApp (Wasender) according to the identifier type, then wipes the ciphertext. The API never returns the token. (O30, D27)
  3. Open the link. The invitee opens the link. The token travels in the URL fragment, so it never reaches a server log; the app posts it to the preview route and shows "Join Restaurant name as Waiter — invited as c***@gmail.com", with sign-in and sign-up buttons and no "create your business" button. (O32 preview)
  4. Invitee with an identity. They sign in. If the invited identifier is already verified on their identity, they accept. If they hold an identity but the invited identifier is not verified on it, they add and verify it through the existing add-sign-in-method flow (OUTBOX_USE_CASES.md OX-S2), then accept. (O32)
  5. Invitee without an identity. They sign up with the same identifier the invitation names and confirm the sign-up code (the ordinary sign-up flow, IDENTITY_CASE_MATRIX.md). The app keeps the token in memory across sign-up and never offers provisioning while it holds one (APP_SHELL_AND_ONBOARDING.md: an invitee does not accidentally create another organization). Then they accept. (O32)
  6. Accept. One database transaction creates the membership, the role assignment and its access scope copied from the invitation, marks the invitation accepted and writes the tenant audit record. If the plan's seat limit is already used, acceptance is refused with a clear code and the invitation stays open. (O32, D29)
  7. The new member appears. The owner's "Team" list shows the manager, the waiter, the cook and the cashier with their role and scope, the open invitations, and seatsUsed / seatLimit. (O25)
  8. Control. The owner can revoke an invitation that is still open (O31), reissue one whose message was lost or that expired (O33), take a role away from a member (O28), and suspend a member, which frees the seat (O29).

Cases per step.

StepCasesRoute
1 (role list)O30 (offerable roles read)GET /offerable-roles?scopeKind=...&scopeId=...&includeDescendants=...
1, 2O30POST /invitations
3O32 (preview)POST /invitation-previews
4, 5none new: sign-in, sign-up, add-sign-in-method (Identity)existing routes
6O32POST /invitation-acceptances
7O25GET /memberships, GET /invitations
8O31, O33, O28, O29POST /invitations/{invitationId}/revocation, POST /invitations/{invitationId}/reissue, DELETE /role-assignments/{roleAssignmentId}, POST /memberships/{membershipId}/suspension, POST /memberships/{membershipId}/reactivation, POST /memberships/{membershipId}/termination

J1 findings: where the drafts of §11 and §12 were wrong ​

Verified against the canonical SQL and the code on staging (3bce74d):

  1. The grants already exist. Every decision table in §11 and §12 said "no grant to ninaku_runtime today". database/access/runtime_grants.sql (migration 0017_runtime_reaches_organization_platform_and_identity_rbac.sql) grants SELECT, INSERT, UPDATE, DELETE on identity.invitations, SELECT, INSERT, UPDATE on identity.invitation_role_assignments, SELECT, INSERT on identity.invitation_access_scopes, SELECT, INSERT, UPDATE on identity.memberships, SELECT, INSERT, UPDATE, DELETE on identity.role_assignments, SELECT, INSERT, DELETE on identity.access_scopes, SELECT on identity.roles/permissions/role_permissions, and EXECUTE on identity.effective_permission, identity.assert_membership_seat_available, identity.assert_invitation_editable and platform.effective_limit.
  2. An invitation has no status column. "Starts pending" means accepted_at IS NULL AND revoked_at IS NULL; "expired" is expires_at <= now() on such a row. identity.protect_invitation_lifecycle() refuses an insert that is not open (identity_invitation_initial_state).
  3. An expired invitation still blocks a new one.invitations_one_open_per_identifier_uq is partial on accepted_at IS NULL AND revoked_at IS NULL only (the SQL comment says it deliberately avoids now()), so an expired, never-revoked invitation keeps the identifier "open". O30 must revoke it in the same transaction before inserting the new one, which is exactly what that SQL comment prescribes.
  4. Reissue is revoke-and-insert, not delete-and-insert.protect_invitation_lifecycle() makes token_hash and expires_at immutable ("revoke and issue another", identity_invitation_provenance). Deleting an open invitation is allowed by the trigger, but it destroys the evidence the audit record points at; the old O33 draft chose delete.
  5. The invitee cannot accept through ordinary RLS. invitations_tenant_policy, memberships_insert_policy, role_assignments_tenant_policy and access_scopes_tenant_policy (0050_security_rls.sql) all require an active tenant context, which requires an active membership the invitee does not have yet. The old O32 draft ("the command copies ...") cannot run as ninaku_runtime. Acceptance needs a narrow SECURITY DEFINER entry point, the same shape as platform.provision_organization_for_caller.
  6. The acceptance trigger does not create the membership.protect_invitation_lifecycle() only checks, on the accepted_at transition, that the invitation is not revoked or expired (identity_invitation_usable) and that the accepting identity is an active human who owns the verified, non-revoked identifier (identity_invitation_verified_recipient). Copying the role assignments and scopes is application work inside the acceptance function.
  7. The security outbox cannot carry an invitation today.identity.security_outbox_messages.challenge_id is NOT NULL with a foreign key to identity.authentication_challenges, whose identity_id is NOT NULL, and an invitee may have no identity. identity.claim_security_outbox_messages inner-joins authentication_challenges, so a row without a challenge would be leased and silently never returned to the worker. The details and the minimal change are in D27.
  8. Only one role exists. The only seeded role is the global organization_owner (with the only seeded permission, organization.account.manage), seeded at the end of database/canonical/0040_identity.sql. identity.roles has no vertical column. Answered by D30: seven system roles, seeded by J1-R.
  9. A person has no name. identity.identities has no display name column, and no other table holds one. The member list can show only the member's verified identifiers.
  10. Revocation audit store. The O28 draft points durable evidence at audit.platform_records; a member changing their own organization is a tenant action, recorded in audit.records through TenantAuditContract in the same transaction (AGENTS.md §4). J1 applies the tenant store to O28 and O29.

Verified on staging at 1b34b25 for the roles decision (D30):

  1. No vertical exists in a real database. platform.verticals is created in database/canonical/0060_platform.sql but no canonical file and no migration inserts a row; only the tests do (database/tests/sql/business_foundation_fixture.sql inserts restaurant, hotel and retail; the provisioning specs insert their own). In a database built by npm run db:prepare, every POST /organizations therefore fails on business_unit_verticals_vertical_fk and answers 422 · organization.provisioning_refused ("One of the verticals does not exist."). O1 was proven only against test-seeded verticals. Slice J1-V (migration 0050) seeds hospitality, restaurant and retail, and O1 is now proven against them.
  2. Provisioning is how verticals become known before O11.platform.provision_organization inserts one platform.business_unit_verticals row (default status = 'active') per requested code for the first business unit, and its completion check (platform_provisioning_configuration_required) refuses an organization without at least one active business-unit vertical. So every provisioned organization has one business unit with at least one active vertical; a business unit registered later through O9 has none until O11 exists.
  3. O11's grant note was wrong. database/access/runtime_grants.sql already grants SELECT on platform.verticals and SELECT, INSERT, UPDATE on platform.business_unit_verticals, and business_unit_verticals_tenant_policy (0060_platform.sql) confines them to the selected tenant. Corrected in O11.
  4. J1-1 collided with staging and was renumbered. The branch feat/invitation-delivery-schema used migration 0049 and baseline revision 59, which staging had already used for 0049_early_payment_keeps_access.sql. It was re-applied on staging as 0053_invitations_travel_through_the_security_outbox.sql at baseline revision 63.

J1 decisions ​

Proposed defaults, reversible, owned by the product owner who delegated them. Each is also recorded in DECISIONS.md (D27 delivery, D28 secret and acceptance, D29 limits, seats, privilege, membership disclosure and expiry, D30 roles by vertical).

#DecisionDefaultRejected alternative and why
aDeliveryA fifth purpose, invitation, in the existing Identity security outbox; the token is sealed with the existing outbox cipher and wiped after delivery; email through SES, phone through Wasender, chosen by identifier_typeBuild the tenant outbox (events.outbox_records) now: no code uses it, the worker does not claim it, and it would mean a second claim loop, a second dead-letter surface and tenant RLS for the worker, all for one message type that is a security message in nature (it carries a bearer secret)
bThe secret32 random bytes, base64url, sent only to the invitee; only sha256(token) is stored in token_hash; no response ever returns it; acceptance needs the token and a session whose identity owns the invited identifier verifiedReturn the link to the inviter so they can share it: a stolen inviter session could then harvest joinable tokens, and the "verified identifier" proof would be the only barrier left
cAbuse by the inviterPending invitations per organization: plan limit identity.pending_invitations (20 on Pionero). New invitations per organization: 30 per rolling 24 hours (SQL constant). Per identifier per organization: one invitation message per hour across invite/reissue. Per identifier across all organizations: 3 messages per rolling 24 hours, beyond that delivery is deferred, not refused. Per IP: Throttler on invite/reissue (20 per 10 minutes) and on preview/accept (ANSWERS_A_CHALLENGE_RATE_LIMIT, 20 per 10 minutes)Rely on the Throttler alone: it is per IP and per process, so one owner session behind several IPs could still make Ninaku spam a third party
dSeatsAn invitation reserves nothing; the existing deferred memberships_seat_limit_trg refuses an acceptance over the limit with 409 · identity.seat_limit_reached, and the invitation stays open; O25 answers seatsUsed and seatLimitReserve a seat per open invitation: idle invitations would block real hires and the seat trigger would need a second source of consumption
ePrivilegeJ1 invitations need organization.account.manage organization-wide; the offered role must be active, global or of this organization, and must not grant organization.account.manage (so never organization_owner); enforced in the use case, again by identity.validate_invitation_role() (extended) and again inside identity.accept_invitationLet any scoped holder of the permission invite inside their scope: the scope-coverage rule below is defined, but no scoped administrator exists yet and the existing Organization commands all require organization-wide
fAlready a memberInvite answers 409 · identity.invitee_already_member when the identifier is verified on an identity with a current (active or suspended) membership of this organization; a non-member identity's existence is never revealedSilently send anyway: the acceptance would fail on memberships_identity_organization_current_uq and the owner would never learn why
gExpiry7 days by default, 1 to 30 days on request; reissue revokes the old row (its token dies at once) and inserts a new row with a new token and a new expiry counted from the reissueExtend the old row: expires_at and token_hash are immutable by trigger, and an extended old token keeps a possibly leaked link alive
hRoles offeredSeven system roles seeded by migration with no permissions (D30): neutral manager, cashier; restaurant: waiter, kitchen, bar; retail: sales_associate, stock_keeper. An invitation offers the neutral roles plus the roles whose vertical is active in at least one business unit the chosen scope coversOne list without verticals (a store owner would be offered "Kitchen"); per-organization copies on vertical activation (N copies for every permission migration); a rank between roles (duplicates permissions and hides what is delegated)
iDelivery seamThe use case depends on InvitationDeliveryPort (schedule(context, delivery), withdraw(context, invitationId), context = the tenant TransactionContext); today's adapter calls identity.enqueue_invitation_message / identity.withdraw_invitation_message; the D29 limits stay in that SQL function, and any future adapter must carry themCall the outbox repository from the use case: welds the use case to one transport and makes a Notifications move a rewrite

Containment rule (general form; J1 builds its first step). An inviter may offer a role at a scope only when all three hold:

  1. every permission of the role is among the inviter's own effective permissions at a scope that covers the offered scope;
  2. the offered scope is covered by one of the inviter's administrative scopes (an access scope of a role assignment that grants organization.account.manage);
  3. the role contains no administrative, non-delegable permission.

Coverage: organization-wide covers everything; a business unit, legal entity, brand or site scope covers the same dimension and value only; an outlet scope covers that outlet, and its descendants only when include_descendants is true on both. Nobody can offer organization-wide unless they hold it.

J1 builds it as: the inviter needs organization.account.manage organization-wide (so 2 holds for any scope), the offered role must not grant organization.account.manage (3, a single-code check), and 1 holds because the offerable roles carry no permissions yet (D30) and, from the day a module gives them some, organization_owner receives the same permissions in the same migration. When a second administrative permission exists, the single-code check becomes an attribute on identity.permissions marking a permission administrative (a new column, not in the schema today). The same rule is checked in the use case, in identity.validate_invitation_role() and inside identity.accept_invitation; identity.enqueue_invitation_message additionally checks in SQL that identity.current_membership_id() holds organization.account.manage organization-wide, because identity.invitations.invited_by_membership_id is nullable and the function otherwise trusts any tenant context (J1-1b).

Offering rule by vertical (D30). The business units a scope covers are: organization-wide → its active business units; a business unit → that one; an outlet → its effective business unit today (organization.effective_business_unit(org, outlet, current_date)), plus those of its descendants with include_descendants; a brand, site or legal entity → the effective business units today of its outlets. A vertical role is offerable when its vertical_code is active in platform.business_unit_verticals for at least one of them; a neutral role always is; a scope that covers no business unit offers only neutral roles.

Owner questions, answered ​

  1. The starter roles. Answered by the owner (2026-10-02) and recorded in D30: seven system roles by vertical, seeded with no permissions.
  2. A co-administrator. Default for J1: no. An invitation never offers a role that grants organization.account.manage, so the owner stays the only administrator. Making a second administrator is a later decision for a later journey, most likely as "promote an existing member" with step-up rather than an invitation, so the person is already known and verified before receiving full control. O26 as built (J1-7) does not do it: it refuses every role that grants organization.account.manage, so nobody becomes an administrator over HTTP and the co-administrator stays a later decision.
  3. A person's name. Not needed for J1: the team list shows the member's verified email or phone. When a name is added it belongs to the person's identity and is set by that person, never typed by the inviter (an inviter-typed label would be an unverified claim shown as fact).

11. Memberships and roles ​

Ownership note: memberships, roles, permissions, role_permissions, role_assignments, and access_scopes physically live in the identity schema (0040_identity.sql), and ARCHITECTURE.md's build order names "membresías" explicitly as part of the Identity module (step 10), built on top of the Organization/Platform topology modules (step 9). This catalog therefore proposes these cases are implemented by Identity, not Organization, with Organization exposing only the organization/business-unit/ brand/site/outlet identifiers that access scopes reference. Organization and Identity collaborate through contracts/, never by importing each other's repositories (D18, R1).

O25 — List organization members ​

Revised for J1.Actor: a member holding organization.account.manage organization-wide (identity.effective_permission(membership, 'organization.account.manage', NULL)), in the tenant named by X-Organization-Id. Intent: see who is in this organization (role and scope of each member), who is invited and not yet in, and how many seats are left. Preconditions: live session; active membership in the selected tenant (O3). Invariants protected: tenant isolation (memberships_select_policy, role_assignments_tenant_policy, access_scopes_tenant_policy, invitations_tenant_policy); the token hash is never read into a response; a member's identifiers are shown only to an administrator of an organization that person belongs to or was invited to. Main path:

  1. GET /memberships — keyset page over identity.memberships of the tenant, (joined_at DESC, id DESC), default 50, maximum 100, opaque signed cursor; each item: membershipId, status, joinedAt, suspendedAt, endedAt, the member's verified, non-revoked identifiers from identity.login_identifiers ({ type, value }; there is no name, J1 finding 9), and roleAssignments, one entry per role assignment and access scope with roleAssignmentId, the role code, name and verticalCode (null for a neutral role), assignedAt and its scope ({ kind, id, includeDescendants }: organization, or the one dimension and id). ?status=active|suspended|ended filters one status; default active,suspended.
  2. meta.seats = { used, limit }: used counts status = 'active' memberships (the same count identity.assert_membership_seat_available uses), limit is platform.effective_limit(org, 'identity.active_memberships') (null when unlimited or undefined). Both are one indexed query each.
  3. GET /invitations — keyset page over identity.invitations of the tenant on invitations_organization_idx (organization_id, created_at DESC) plus id; each item: invitationId, identifierType, the masked identifier (as POST /invitations answers it; the stored address is never returned), state (open, expired, accepted, revoked, derived from the timestamps), expiresAt, createdAt, the inviter's membershipId, replacesInvitationId, the offered roles (roleId, code) with their scopes, and deliveryState, the state of the newest message of the invitation (pending for pending or leased, sent, failed for a dead letter, null when there is no message) without any ciphertext or destination. ?state=open|expired|accepted|revoked filters one state; default open,expired, the invitations still waiting for an answer. Adversarial / edge cases:
PathAnswer
No session401 · identity.session_required
X-Organization-Id of an organization the caller has no active membership in403 · tenant.context_rejected
Member without organization.account.manage organization-wide (a cashier, or a manager scoped to one outlet)403 · identity.action_forbidden; no audit record (reads are not audited), the refusal is logged and metered
Tampered, expired or foreign cursor400 · collection.cursor_tampered / collection.cursor_expired / collection.cursor_incompatible
Another tenant's rowsNever returned (RLS), even with a hand-made cursor
A role assignment with no access scopeCannot exist (role_assignments_scoped_trg is deferred and requires one); not a branch
An invitation's token_hashNever selected by the read model

Audit: reads are not audited. Idempotency: a read. ETag: none (D24: paginated lists do not carry one); Cache-Control: private, no-cache. Plan limit: reads identity.active_memberships, enforces nothing. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — GET /memberships, GET /invitationsRead modelThe invitation list is folded in here (J1 step 7)Identityidentity.memberships, identity.login_identifiers, identity.role_assignments, identity.roles, identity.access_scopes, identity.invitations, identity.invitation_role_assignments, identity.invitation_access_scopes, platform.effective_limit(); the delivery outcome comes from a new SECURITY DEFINER read identity.invitation_delivery_states(uuid[]) because the runtime has no SELECT on identity.security_outbox_messagesNone for the tables (migration 0017); EXECUTE on the new delivery-state function. A keyset index on identity.memberships (organization_id, joined_at DESC, id DESC) is added only if EXPLAIN (ANALYZE, BUFFERS) shows a sort or a sequential scan (D24)

O26 — Assign a role to a member ​

As built (J1-7): POST /memberships/{membershipId}/role-assignments with { roleId, scope } answers 201 with { roleAssignmentId, membershipId, roleCode, scope }, or 200 with the same body when the member already holds that role at that scope. The journey still gives a member their first roles through the invitation (D30); O26 is how a member whose role was taken away (O28), or who needs one more, gets another without being invited again. Details in "J1-7 as built".

Actor: a member with organization.account.manage at a scope that covers the target scope being granted. Intent: grant an existing member a role at a specific scope (org-wide, or narrowed to a business unit/legal entity/brand/site/outlet). Preconditions: target membership is active; role is active and either global or belongs to the same organization; not already assigned ((membership_id, role_id) unique). Invariants protected: a role assignment must carry exactly one scope dimension (or none, for org-wide) — access_scopes's single-dimension check and the deferred role_assignments_scoped_trg both apply; adding a seat also re-checks platform.effective_limit(org, 'identity.active_memberships') if this assignment is bundled with a new membership (it usually is not — memberships are created by O1/O32, not here — but the seat-limit adversarial case is listed under O32 for that reason). Main path: submit membership_id, role_id, scope → INSERT INTO identity.role_assignments + INSERT INTO identity.access_scopes in the same transaction. J1 note: an invited person's first role assignment is created by O32 from the invitation, never by this route. O26 applies the same privilege rule as O30 (J1 decision e: the role must not grant organization.account.manage, the caller needs it organization-wide) and the same codes (403 · identity.action_forbidden, 403 · identity.role_assignment_privilege_exceeded, and 422 · identity.role_assignment_refused for an unknown, inactive or foreign role or scope target). It also applies the D30 offering rule: a vertical role (waiter, kitchen, bar, sales_associate, stock_keeper) is assignable only at a scope that covers at least one business unit with that vertical active, refused with 422 · identity.role_assignment_vertical_inactive; the SQL backstop is the same coverage function J1-R adds, called from the trigger access_scopes_vertical_trg that migration 0062 adds on identity.access_scopes (the role row is inserted before its scope, so the check cannot live on identity.role_assignments). The general containment rule (J1 decisions) replaces the single-code check once scoped administrators exist. Adversarial / edge cases:

  • Role already assigned to this membership — refused by the unique index.
  • Role belongs to a different organization — refused by the FK.
  • Scope names more than one dimension at once (e.g., both a business unit and an outlet) — refused by the single-dimension check. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /memberships/{id}/role-assignmentsCommand—Identityidentity.role_assignments, identity.access_scopesNo grant to ninaku_runtime today

O27 — Change a role assignment's access scope ​

As built (J1-7): PATCH /role-assignments/{roleAssignmentId}/scope with { scope } answers 200 with { roleAssignmentId, membershipId, roleCode, scope }. The D21 path it names (narrowing the last administrator's scope) is refused inside the call with 409 · identity.last_administrator_required. Details in "J1-7 as built".

Actor: same as O26. Intent: narrow or widen where an existing role assignment applies (e.g., move an administrator's scope from org-wide to one outlet). Invariants protected: D21 explicitly names "reducir su alcance a una sucursal o a otra dimensión" as one of the paths that must not leave an active, admitted organization without an org-wide administrator — this is not a variant of O26, it is the exact case D21 calls out by name. Main path: because identity.access_scopes has no update-tracking column and role assignments carry no revision history, this case is modeled as delete the old access scope, insert the new one, in the same transaction that also re-evaluates the deferred D21 trigger — never an in-place field update, so provenance is never silently lost. Adversarial / edge cases:

  • Narrowing the organization's last org-wide administrator's scope down to one outlet — refused at commit by assert_administration_preserved.
  • Narrowing scope while another org-wide administrator already exists — allowed. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — PATCH /role-assignments/{id}/scopeCommand—Identityidentity.access_scopes, transitively identity.assert_administration_preservedNo grant to ninaku_runtime today

O28 — Revoke a role assignment ​

As built (J1-6): DELETE /role-assignments/{roleAssignmentId} answers 200 with { roleAssignmentId, membershipId, roleCode } (the draft said 204); the repository deletes the scope rows, then the assignment, and settles the deferred checks inside the call, so D21 is answered 409 · identity.last_administrator_required instead of surfacing at commit. Details in "J1-6 as built".

J1 cross-reference: used by J1 step 8 ("control" slice). The audit record is a tenant record (audit.records, TenantAuditContract, same transaction), not audit.platform_records (J1 finding 10).

Actor: same as O26. Intent: take a role away from a member. Invariants protected: D21 (deleting the last administrative role assignment for an org is one of the nine trigger-guarded tables); this is the aggregate that breaks "never delete" — identity.role_assignments has no soft-lifecycle column, so revocation is a real DELETE, with only assigned_at/assigned_by (already written) as provenance; anything more durable than that lives in the audit trail (audit.platform_records), not in this table. Main path: DELETE FROM identity.access_scopes WHERE role_assignment_id = ... then DELETE FROM identity.role_assignments WHERE id = ..., in one transaction; deferred D21 trigger evaluates at commit. Adversarial / edge cases:

  • Revoking the organization's last org-wide administrative role assignment — refused at commit.
  • Revoking while a second org-wide administrator exists — allowed.
  • Concurrent revoke of two different administrators' assignments in the same organization — the per-organization advisory lock (identity.lock_administration_tenant) serializes them so the check never race-passes both. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — DELETE /role-assignments/{id}Command—Identityidentity.role_assignments, identity.access_scopesNo grant to ninaku_runtime today

O29 — Suspend or end a membership ​

As built (J1-6): three routes, POST /memberships/{membershipId}/suspension, /reactivation and /termination, each 200 with { membershipId, status, suspendedAt, endedAt }. Reactivation is added to the draft: it takes a seat again and is refused over the limit (409 · identity.seat_limit_reached). Ending is final (409 · identity.membership_ended on suspension or reactivation of an ended membership). Details in "J1-6 as built".

J1 cross-reference: used by J1 step 8 ("control" slice). Suspending frees a seat (identity.check_membership_seat counts status = 'active' only); reactivating a suspended membership consumes one again and is refused over the limit. Audited in the tenant store (J1 finding 10).

Actor: same as O26. Intent: temporarily suspend a member, or end their relationship with the organization for good. Invariants protected: D21 (memberships are one of the nine trigger-guarded tables); ending is terminal — a person who later rejoins gets a new memberships row (identity.validate_membership_transition), never a reopened old one. Main path: UPDATE identity.memberships SET status = 'suspended', ... or SET status = 'ended', ended_at = now(). Adversarial / edge cases:

  • Suspending/ending the organization's last active org-wide administrator's membership — refused at commit by D21.
  • Re-inviting a person whose membership previously ended — creates a new membership row (O32/O31), not a reactivation of the old one. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /memberships/{id}/suspension, .../terminationCommand—Identityidentity.membershipsNo grant to ninaku_runtime today

12. Invitations ​

Ownership note: same as §11 — identity.invitations and its child tables live in the identity schema; proposed owner is Identity, with Organization contributing only the target organization/scope identifiers.

Codes used by O30 to O33. Every refusal is a Problem Details body (FOUNDATION_HTTP_AND_OBSERVABILITY.md), detail in English (D25). New codes are marked new; the others exist today.

Status · codeMeaning
400 · foundation.validation_failedBody or path fails its strict Zod schema (unknown field, bad UUID, identifier not an email or an E.164 phone, expiresInDays outside 1 to 30, more than one scope dimension in one scope object, NUL character)
401 · identity.session_requiredNo live session
403 · tenant.context_rejectedX-Organization-Id names an organization the caller has no active membership in
403 · identity.action_forbidden newThe caller lacks organization.account.manage organization-wide; mirrors organization.action_forbidden
403 · identity.invitation_privilege_exceeded newThe offered role grants organization.account.manage (J1 decision e)
404 · identity.invitation_not_found newNo invitation with this id in this tenant (O31, O33), or no invitation with this token (O32)
409 · identity.invitation_closed newThe invitation is accepted, revoked or expired (the three are not told apart), or its organization is not active
409 · identity.invitation_already_open newAn open, unexpired invitation for this identifier exists with a different role or scope configuration
409 · identity.invitee_already_member newThe identifier is verified on an identity with a current membership of this organization (O30), or the accepting identity already has one (O32)
409 · identity.invitation_pending_limit_reached newThe organization already has as many open invitations as its plan's identity.pending_invitations
409 · identity.seat_limit_reached newAccepting would exceed identity.active_memberships (identity_membership_seat_limit)
403 · identity.invitation_recipient_mismatch newA valid token, but the caller's identity does not own the invited identifier verified
422 · identity.invitation_refused newRole unknown, inactive or archived (a retired role, D30) or of another organization; scope target unknown, inactive or of another organization; includeDescendants without an outlet
422 · identity.invitation_role_vertical_inactive newA vertical role offered at a scope where its vertical is active in no covered business unit (D30), at invitation or, after a deactivation, at acceptance
429 · identity.invitation_rate_limited newOrganization daily cap or per-identifier cooldown (J1 decision c); carries Retry-After
429 · rate_limit.exceededThe per-IP Throttler

O30 — Invite a person to the organization ​

Revised for J1.Actor: a member holding organization.account.manage organization-wide (J1 decision e). Intent: offer membership, with one or more pre-configured roles and scopes, to a person known only by an email or a phone, whether or not they have a Ninaku identity, and get the offer to them. Preconditions: live session; tenant context (O3); the organization is active. Invariants protected:

  • The invitation targets a bare identifier, never an identity_id; whether an identity exists is decided only at acceptance (O32). The response is the same whether or not the identifier belongs to someone, except the already-member case of this organization (J1 decision f).
  • The token exists in clear only in memory while it is generated, hashed into token_hash and sealed into the outbox row; no response, log or audit record carries it (D28).
  • One open invitation per (organization_id, identifier_type, normalized_value) (invitations_one_open_per_identifier_uq).
  • The invitation row, its invitation_role_assignments, its invitation_access_scopes, its outbox message and its audit record commit or roll back together.
  • Roles and scopes are frozen once the invitation closes (identity.assert_invitation_editable, identity_invitation_editable). Main path:
  1. POST /invitations with identifierType (email | phone), identifier, roles (1 to 5 items, each roleId plus one scope: { "kind": "organization" } or { "kind": "business_unit" | "legal_entity" | "brand" | "site" | "outlet", "id": uuid, "includeDescendants"?: boolean }), optional expiresInDays (1 to 30, default 7).
  2. Normalize the identifier with the same normalizer sign-up uses (lower-cased email; E.164 phone), so acceptance matches login_identifiers.normalized_value exactly.
  3. Check the permission (identity.effective_permission(caller, 'organization.account.manage', NULL)); a refusal is audited and answered 403.
  4. Check each role: exists, active, global or of this organization, and grants no organization.account.manage (the J1 step of the containment rule); check each scope target belongs to this organization and is active; check each vertical role against its scope with the offering rule (D30): its vertical_code must be active in at least one business unit the scope covers.
  5. If the identifier is verified (verified_at IS NOT NULL AND revoked_at IS NULL) on an identity holding a membership of this organization with ended_at IS NULL, answer 409 · identity.invitee_already_member.
  6. Lock the open invitation for the identifier, if any (FOR UPDATE):
    • open, unexpired, same role/scope set → answer 200 with it, enqueue nothing (natural idempotency, below);
    • open, unexpired, different set → 409 · identity.invitation_already_open;
    • open but expired → set its revoked_at = now() in this transaction (J1 finding 3), audited as identity.invitation.revoked with reason expired_replaced.
  7. Insert identity.invitations (token_hash = sha256(token), invited_by_membership_id = caller's membership, expires_at = now() + expiresInDays), then one identity.invitation_role_assignments row and one identity.invitation_access_scopes row per offered role.
  8. Call InvitationDeliveryPort.schedule(context, { invitationId, token }) with the same tenant TransactionContext (J1 decision i). Today's adapter seals the token with the outbox cipher and calls identity.enqueue_invitation_message(message_id, invitation_id, ciphertext, nonce, key_version) (D27). The function takes the destination from the invitation row itself (the API cannot send to any other address), checks that the calling membership holds organization.account.manage organization-wide (J1-1b), enforces the pending cap, the daily cap and the per-identifier cooldown under a per-organization and a per-identifier advisory lock, and defers available_at when the cross-organization cap is reached, never later than one hour before the invitation's expires_at and counting only the other invitations' pending, leased and published messages (D29, J1-1b).
  9. Record the tenant audit record and commit. Answer 201 with the invitation (no token, no link). Adversarial paths:
PathAnswer
No session / foreign tenant401 · identity.session_required / 403 · tenant.context_rejected
A cashier, a waiter, or a manager (any scope: manager grants no organization.account.manage) invites403 · identity.action_forbidden, audited as denied, nothing else written
Any tenant session calls identity.enqueue_invitation_message without organization.account.manage organization-wide (a compromised API path)The function raises 42501 with constraint identity_invitation_inviter_forbidden (new, J1-1b), nothing enqueued; the use case checked first, so over HTTP this surfaces only as the 403 above or, if the use case were bypassed, an unmapped 500
Offered role is organization_owner, or any role granting organization.account.manage403 · identity.invitation_privilege_exceeded, audited as denied
Role of another organization, inactive role, unknown role422 · identity.invitation_refused (backstop: identity.validate_invitation_role())
A retired system role (status inactive or archived, D30)422 · identity.invitation_refused (backstop: identity.validate_role_assignment, called by identity.validate_invitation_role())
Ana (restaurant only) offers stock_keeper, or any vertical role at a scope whose business units do not run its vertical (including an outlet with no effective business unit)422 · identity.invitation_role_vertical_inactive, nothing written (backstop: the new trigger on identity.invitation_access_scopes, J1-R)
manager or cashier at any scope, or waiter organization-wide when one business unit runs restaurant201: neutral roles are always offerable; organization-wide covers every active business unit
Scope target of another organization or unknown422 · identity.invitation_refused (backstop: the composite foreign keys invitation_access_scopes_*_organization_fk)
Two dimensions in one scope; includeDescendants on a non-outlet scope400 · foundation.validation_failed (backstops: invitation_access_scopes_single_dimension_chk, invitation_scopes_descendants_chk)
The same role offered twice400 · foundation.validation_failed (backstop: invitation_role_assignments_invitation_role_uq)
Identifier verified on a current member (active or suspended) of this organization409 · identity.invitee_already_member
Identifier verified on someone who is not a member, or on nobody201, the same body shape and timing class; nothing reveals which
Open, unexpired invitation for the identifier with another configuration409 · identity.invitation_already_open; the owner revokes (O31) or reissues (O33)
Exact retry of a successful request200 with the same invitation, no second message
Two identical requests raceOne inserts; the other hits invitations_one_open_per_identifier_uq, re-reads and answers 200 with the winner (or 409 if the configuration differs)
Open invitations already at identity.pending_invitations409 · identity.invitation_pending_limit_reached
More than 30 invitations in 24 hours for this organization, or a second message to the same identifier within one hour429 · identity.invitation_rate_limited with Retry-After
The identifier already received 3 invitation messages in 24 hours from any organizations201; the message is enqueued with a later available_at (no refusal, so nothing leaks about other organizations)
More than 20 requests in 10 minutes from one IP429 · rate_limit.exceeded
Organization suspended or archived403 · tenant.context_rejected (an inactive organization yields no tenant context)

Offerable roles read (J1 step 1). GET /offerable-roles?scopeKind=organization|business_unit|legal_entity|brand|site|outlet&scopeId=<uuid>&includeDescendants=<bool> answers the roles the caller may offer at that scope, computed with the same coverage function as the SQL backstop, so the form and the refusal never disagree: each item roleId, code, name (English; the app shows its own label from code, D30), verticalCode (null for neutral). Same actor as O30 and, as built, the codes of the structure routes (401, 403 · tenant.context_rejected, 403 · organization.action_forbidden, 404 · organization.structure_not_found for an unknown or foreign scope target, 400 · foundation.validation_failed for a malformed query or a scopeId with scopeKind=organization). Never lists organization_owner, a retired role, or another organization's custom role. Not paginated (at most a few dozen rows), no ETag (it changes with O11), Cache-Control: private, no-cache. Not audited.

Audit: identity.invitation.created (new action type, new resource kind identity.invitation), outcome success, target the invitation; changes after-only: invitation.identifier_type, invitation.identifier masked (c***@gmail.com, +57*******12), invitation.role_codes, invitation.scope per role, invitation.expires_at. Denied attempts write the same action with outcome denied and no changes. Never the token. Idempotency: natural, no Idempotency-Key: the open-invitation index plus "same configuration answers the existing one" makes a retry harmless, and no second message is sent. Plan limit: identity.pending_invitations (new limit definition) is checked; identity.active_memberships is not (J1 decision d). Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /invitations; GET /offerable-roles (read, below)CommandAbsorbs the expired-open auto-revokeIdentityidentity.invitations, identity.invitation_role_assignments, identity.invitation_access_scopes, identity.login_identifiers, identity.memberships, identity.roles (with the new vertical_code), identity.role_permissions, platform.business_unit_verticals and organization.effective_business_unit() (through the J1-R coverage function), identity.security_outbox_messages (through the new function, behind InvitationDeliveryPort), audit.recordsTables: none (migration 0017). New: EXECUTE on identity.enqueue_invitation_message; seeds for the identity.invitation resource kind, the identity.invitation.* action types and the identity.pending_invitations limit definition

O31 — Revoke an invitation ​

Revised for J1.Actor: same as O30 (any organization-wide administrator, not only the inviter: the inviter may have left). Intent: withdraw an offer before it is accepted, so the link stops working at once. Preconditions: live session; tenant context; the invitation belongs to this tenant. Invariants protected: invitations_terminal_state_chk and identity_invitation_terminal (an accepted or revoked invitation never changes again); a revoked invitation is preserved, never deleted; once revoked, identity_invitation_usable refuses its acceptance. Main path: POST /invitations/{invitationId}/revocation → SELECT ... FOR UPDATE the row in the tenant → UPDATE identity.invitations SET revoked_at = now() → InvitationDeliveryPort.withdraw(context, invitationId), whose adapter dead-letters its still-pending message, if any, with last_failure_class = 'invitation_revoked' and the ciphertext wiped (new SECURITY DEFINER identity.withdraw_invitation_message(uuid), because the runtime cannot update identity.security_outbox_messages); a message already leased is not touched here, and after J1-1b the claim and identity.record_security_outbox_attempt refuse to deliver or requeue a message whose invitation is revoked or expired → audit → 200 with the invitation in state revoked. Adversarial paths:

PathAnswer
No session / foreign tenant / not an administrator401 · identity.session_required / 403 · tenant.context_rejected / 403 · identity.action_forbidden (audited as denied)
Unknown id, or another tenant's invitation id404 · identity.invitation_not_found (RLS makes them the same)
Already revoked200 with the same invitation; nothing written, no second audit record
Already accepted409 · identity.invitation_closed; the owner uses O28 or O29 on the member instead
Open but expired200, revoked (it frees the identifier for a fresh O30)
Revocation races an acceptanceBoth lock the row; whichever commits first wins, the other gets 409 · identity.invitation_closed
The message was already sentRevocation still wins: the token can no longer be accepted

Audit: identity.invitation.revoked, target the invitation, change invitation.status before pending and after revoked (as built, the same attribute the J1-2 revocation of an expired invitation records, not invitation.revoked_at), reason revoked_by_member. Idempotency: natural (a revoked invitation answers itself). Plan limit: frees one identity.pending_invitations slot. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /invitations/{invitationId}/revocationCommand—Identityidentity.invitations, identity.security_outbox_messages (through the new function), audit.recordsTables: none. New: EXECUTE on identity.withdraw_invitation_message

O32 — Accept an invitation ​

Revised for J1.Actor: the invitee, signed in (the one without an identity signs up and confirms the invited identifier first, J1 step 5). Intent: join the organization under the roles and scopes the invitation promised, without ever creating a second organization by mistake (APP_SHELL_AND_ONBOARDING.md). Preconditions: the invitation is open and unexpired; the organization is active; the accepting identity is an active human that owns a verified, non-revoked login_identifiers row with the invitation's (identifier_type, normalized_value); a seat is free. Invariants protected:

  • Only a token holder learns anything about an invitation; an unknown token answers the same as any other unknown token (D28).
  • The accepting identity proves possession of the invited identifier (identity_invitation_verified_recipient), so a forwarded or stolen link alone is not enough.
  • Membership, role assignments, access scopes, accepted_at / accepted_identity_id and the audit record commit together; role_assignments_scoped_trg and memberships_seat_limit_trg are deferred and judge the final state at commit.
  • One current membership per identity and organization (memberships_identity_organization_current_uq). Main path:
  1. Preview (step 3 of J1): POST /invitation-previews with { "token": "..." }, session optional. Answers the organization name, the offered role names and scope labels, expiresAt, and the masked identifier, or 404 · identity.invitation_not_found for an unknown token and 409 · identity.invitation_closed for a closed one. It writes nothing. Read through a new SECURITY DEFINER identity.preview_invitation(bytea), because the invitee has no tenant context.
  2. POST /invitation-acceptances with { "token": "..." } and a session. The token is in the body, never in a path or query string.
  3. The use case hashes the token and calls the new SECURITY DEFINERidentity.accept_invitation(p_token_hash bytea) (D28). The function takes the caller from app.identity_id, never from a parameter, then: locks the invitation by token_hash FOR UPDATE; refuses a closed one; refuses an inactive organization; refuses a caller who already has a current membership; inserts identity.memberships (active); copies each invitation_role_assignments row into identity.role_assignments (assigned_by = the inviter's identity, when the inviter's membership still exists) and each invitation_access_scopes row into identity.access_scopes; re-checks that no copied role grants organization.account.manage and that each vertical role is still offerable at its scope (D30); sets accepted_at = statement_timestamp() and accepted_identity_id, which fires the recipient check; writes the tenant audit record with the request settings swapped to the new membership, as platform.record_organization_provisioned does; returns the organization id and membership id.
  4. Answer 201 with { organizationId, organizationName, membershipId, roleCodes }. The app selects that organization (O3) and opens it; it never shows provisioning. Adversarial paths:
PathAnswer
No session401 · identity.session_required (the preview still works)
Token unknown or guessed404 · identity.invitation_not_found; identical body for every unknown token
Token that is not exactly 43 base64url characters, an unknown field, no token400 · foundation.validation_failed; the shape of a token says nothing about any invitation
Token of an expired, revoked or already-accepted invitation409 · identity.invitation_closed (the holder learns only that this link is dead)
Organization suspended or archived since the invitation409 · identity.invitation_closed
Caller's identity does not own the identifier at all, or owns it unverified403 · identity.invitation_recipient_mismatch; detail says to sign in with, or verify, the invited identifier; it never says whether another identity owns it
Caller already has a current membership in that organization409 · identity.invitee_already_member
Same identity retries after a success201 with the same organizationId and membershipId, nothing written again
Seat limit already used409 · identity.seat_limit_reached; the invitation stays open, so it can be accepted after the owner frees a seat (O29)
Two identities race with the same token (forwarded link)Only the one that owns the verified identifier can pass; if both do (impossible: an identifier is verified on one identity), the row lock serializes them and the second gets 409 · identity.invitation_closed
Two invitations for the same person (email and phone) accepted concurrentlyThe second hits memberships_identity_organization_current_uq → 409 · identity.invitee_already_member; its invitation stays open for the owner to revoke
The last seat raced by two different inviteesidentity.assert_membership_seat_available takes pg_advisory_xact_lock per organization; one commits, the other gets 409 · identity.seat_limit_reached
A role's vertical was deactivated (O11) in every business unit the offered scope covers, after the invitation was sent422 · identity.invitation_role_vertical_inactive; nothing written, the invitation stays open for the owner to revoke
A role on the invitation was deactivated, or made administrative, after it was sent422 · identity.invitation_refused; nothing is written and the invitation stays open, so the owner can revoke it and invite again with another role
More than 20 previews or acceptances in 10 minutes from one IP429 · rate_limit.exceeded
Invitee without an identity tries to accept before confirming sign-up401 · identity.session_required (no session exists until confirmation)

Audit: identity.invitation.accepted, actor the invitee, membership the new membership, targets the invitation and the new membership; changes after-only: membership.status, invitation.role_codes, invitation.scope. Refused acceptances write nothing (no tenant to write into, and recording them would reveal token validity to the organization). Idempotency: natural (an invitation accepted by the caller answers the membership it created). Plan limit: identity.active_memberships through the existing deferred memberships_seat_limit_trg. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /invitation-acceptances, POST /invitation-previewsCommand, plus a readThe preview is folded in (J1 step 3)Identityidentity.invitations, identity.invitation_role_assignments, identity.invitation_access_scopes, identity.memberships, identity.role_assignments, identity.access_scopes, identity.login_identifiers, organization.organizations, audit.records, through the new identity.accept_invitation(bytea) and identity.preview_invitation(bytea)New: EXECUTE on both functions for ninaku_runtime; both SECURITY DEFINER, REVOKE ALL ... FROM PUBLIC

O33 — Reissue an invitation ​

Revised for J1.Actor: same as O30. Intent: send the same offer again with a fresh link, when the message was lost, the invitee asks again, or the invitation expired, without retyping the roles and scopes. Preconditions: the invitation belongs to this tenant and is open (expired or not). Invariants protected: token_hash and expires_at are immutable (identity_invitation_provenance), so the old row is revoked, never edited; evidence is preserved, never deleted (J1 finding 4); the old token dies in the same transaction the new one is born. Main path: POST /invitations/{invitationId}/reissue with optional expiresInDays (1 to 30, default 7) → lock the old row → revoked_at = now() → withdraw its pending message (identity.withdraw_invitation_message) → insert a new invitation with the same identifier, a new token, the new expiry and replaces_invitation_id = the old id (new column, below) → copy its role assignments and scopes → enqueue through identity.enqueue_invitation_message (cooldown applies) → audit → 201 with the new invitation. Adversarial paths:

PathAnswer
No session / foreign tenant / not an administrator401 / 403 · tenant.context_rejected / 403 · identity.action_forbidden (audited as denied)
Unknown id or another tenant's404 · identity.invitation_not_found
Accepted, or revoked by O31409 · identity.invitation_closed
Retry of a reissue that already succeeded (the old id is revoked and its replacement is open)201 with the existing replacement, no new message
Reissue within one hour of the last message to the identifier429 · identity.invitation_rate_limited with Retry-After; nothing is revoked
An offered role became inactive or administrative since422 · identity.invitation_refused / 403 · identity.invitation_privilege_exceeded; nothing is revoked
An offered role's vertical is no longer active in the scope422 · identity.invitation_role_vertical_inactive; nothing is revoked
Two reissues of the same invitation raceThe row lock serializes them; the second sees the replacement and answers 201 with it

Audit: identity.invitation.reissued, targets the old and the new invitation; changes: invitation.expires_at before/after. As built, the audit contract takes one target per record, so the old invitation is audited as identity.invitation.revoked (reason reissued, invitation.status before and after) and the new one as identity.invitation.reissued (invitation.replaces after-only, invitation.expires_at before the old expiry and after the new one). Idempotency: natural, through replaces_invitation_id. Plan limit: neutral for identity.pending_invitations (one closes, one opens); the daily cap counts it. Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Yes — POST /invitations/{invitationId}/reissue (revoke + insert as one command)CommandReuses O30's insert and enqueue pathIdentityidentity.invitations plus a new nullable column replaces_invitation_id uuid with a unique index and a same-organization foreign key, needed because nothing links a replacement to the invitation it replaced (natural idempotency and the history both need it); child tables; outbox through the new functions; audit.recordsTables: none. New: the column, both functions' EXECUTE

13. Authorization contract and policy override ​

O34 — Authorize an operation (internal contract) ​

Actor: another module (e.g., Sales, Inventory), never an HTTP caller directly. Intent: ask, in one call, "may this membership do X at this scope?" before executing a command that needs both a permission and (sometimes) an entitlement. Preconditions: tenant context established (O3). Invariants protected: organization-wide RLS alone is not action-level authorization — platform.assert_operation_allowed's own header comment says so explicitly; this case is what makes that distinction real at the application layer, not just in SQL comments. Main path (as built, lot E): the calling module, inside its own tenant transaction, calls OperationAuthorizationContract.decide(context, { permissionCode, capabilityCode, outletId?, verticalCode? }) (src/modules/platform/contracts/operation-authorization.contract.ts). The caller is always the member of the tenant context: there is no membership argument, so nobody can ask on behalf of someone else. The implementation issues exactly one statement, SELECT platform.authorize_operation($1, $2, $3, $4), an invoker function with a pinned search_path that never raises and answers one decision. A malformed request (an empty or longer than 120 characters code, a NUL character, an outlet that is not a UUID) answers permission_missing without reaching the database.

Decision order. Each question is asked only when every earlier one passed, so a caller without the permission never learns anything about the plan, the place or the vertical:

OrderDecisionMeaningPublic answer (mapped by the consuming module)
1tenant_context_missingidentity.has_active_tenant_context() is false (no context, suspended or ended membership, suspended organization, blocked identity)The plain refusal of the consuming module; through a route the tenant guard already answers 403 tenant.context_rejected
2permission_missingidentity.effective_permission refuses and the member never held a covering assignmentThe plain refusal of the consuming module, identical to any other plain denial
3scope_unavailableidentity.permission_scope_unavailable: the member holds the permission through a scope whose place or the outlet is inactive403 authorization.scope_unavailable, "The place you are assigned to is not available. Contact your administrator."
4capability_not_entitledplatform.effective_capability is not enabled (no plan inclusion, a deny override, an expired grant, a lapsed or suspended subscription, an unknown capability)403 platform.capability_not_entitled, "Your plan does not include this."
5outlet_not_operationalorganization.assert_outlet_operational refuses the outlet, an ancestor, its site, brand or business unit422 authorization.outlet_not_operational
6vertical_inactiveThe business unit of the outlet does not run the vertical; a vertical asked without an outlet has no business unit and is always refused (the previous behaviour, kept)422 authorization.vertical_inactive
7allowedEvery check passed—

The outlet, its ancestors and its business unit are resolved on the date of the timezone of the site of the outlet (the previous gate passed current_date). platform.assert_operation_allowed keeps its signature and becomes the raising backstop: it raises 42501 with the constraint platform_operation_<decision> and the decision as hint. The non-raising function is what the application calls: a refusal never aborts the tenant transaction, so the denied audit record of the consuming module commits with it. OperationRefusedError (src/modules/platform/contracts/operation-refusal.ts) lets the consuming module throw after its transaction commits; the consuming module maps the refusal at its own HTTP boundary, and the reference mapping operationRefusalFailure(refusal, plainDenial) (src/modules/platform/presentation/failures/operation-refusal-failure.ts) takes its own plain denial for permission_missing and tenant_context_missing.

Capability catalog and plans (D14). Migration 0071 seeds the global capability codes organization.structure and identity.team and a published Pionero version 3 with the whole catalog, 20 active memberships and 20 pending invitations; version 2 and its offerings retire and no subscription moves by itself. Staff move an organization with the existing POST /platform-admin/organizations/{organizationId}/subscription/plan-changes route. Staff can never assign a trial offering, so a move onto Pionero goes through a private, zero-trial offering of version 3 targeted at the organization (drafted and published with the existing offering routes). Staff administer capabilities and limits through five routes, all POST, staff-only, with a live elevation and an Idempotency-Key, audited only on a real change:

RouteAnswers
/platform-admin/plan-versions/{versionId}/capabilities201 attached now, 200 already attached; 404 platform.plan_version_not_found; 409 platform.plan_version_not_draft; 422 platform.capability_command_refused (unknown or deprecated)
/platform-admin/plan-versions/{versionId}/capabilities/{capabilityCode}/removals204; 404, 409 and 422 as above
/platform-admin/plan-versions/{versionId}/limits201 declared now, 200 changed or the same; 404, 409; 422 unknown or deprecated limit
/platform-admin/organizations/{organizationId}/capability-overrides201 with the override id (grant or deny, reason, optional expiry); 404 platform.organization_not_found; 409 platform.capability_override_conflict while another override of the capability is in force; 422 unknown or deprecated capability or an expiry already past
/platform-admin/capability-overrides/{overrideId}/revocation204 (ending one already ended changes nothing); 404 platform.capability_override_not_found

A caller who is not active staff is refused with 403 platform.staff_required and one without a live elevation with 403 platform.elevation_required.

No Organization command is capability-gated (D15). A lapsed subscription must never stop an administrator from repairing the structure or the team; the seat limit already follows this rule. The first consumer is proven at the contract level and through a test-only route; the first operational module gates its commands and exposes the refusals over HTTP.

Adversarial / edge cases:

  • Caller has the permission but the organization's plan doesn't entitle the capability — capability_not_entitled, distinctly from a plain permission refusal.
  • Caller has the permission and entitlement, but the target outlet is itself inactive or has an inactive ancestor — outlet_not_operational; a member whose only covering scope is the closed place gets scope_unavailable.
  • A stranger, a suspended member, a member of another organization and an outlet of another organization or an unknown one never get anything but permission_missing or tenant_context_missing.

Decision:

HTTP?Command/Read?Merged withModuleDB objectsMissing grants
No tenant route; five staff routes administer the catalogRead (a check, not a mutation)—Platform owns OperationAuthorizationContract; Identity keeps PermissionCheckContract for callers that only need the permission pieceplatform.authorize_operation(), platform.assert_operation_allowed(), platform.effective_capability(), identity.effective_permission(), identity.permission_scope_unavailable(), five *_as_staff functionsNone: ninaku_runtime has EXECUTE on the decision function and the five staff functions, never on the helpers platform.lock_draft_plan_version and platform.capability_id_for_staff

O35 — Override verification-code / TOTP policy for an organization ​

Actor: platform ops or an organization administrator (scope TBD by the permission catalog — this is explicitly a forward-looking case, not yet scheduled). Intent: let one organization use different out-of-band-code or TOTP parameters than the platform default, without touching a single use case in Identity. Preconditions: none yet — the override table does not exist in the six canonical files read for this catalog. Invariants protected: D22's forward-looking rule — VerificationPolicyPort is the only door a use case uses to reach these policies (outOfBandCodePolicy(), totpPolicy(), totpPolicyForMethod(stored)); when the override table arrives "junto al módulo organization," it replaces only the port's implementation — no use case changes. Main path (once built): Organization module owns the override table; its infrastructure layer implements VerificationPolicyPort by reading the override for the current organization (falling back to the platform default when none exists) and constructing the same OutOfBandCodePolicy/ TotpPolicy value objects Identity already consumes. Adversarial / edge cases (once built):

  • An override changes policy after some authenticators were already enrolled — totpPolicyForMethod(stored) must keep reconstructing the original policy from the stored method's own digits/algorithm/ period_seconds, exactly as it does today for the global default, so a policy change never locks out prior enrolments.
  • Override exists but is malformed/incomplete — falls back to the platform default rather than failing every verification for that tenant. Decision:
HTTP?Command/Read?Merged withModuleDB objectsMissing grants
Deferred — no route proposed yet; internal port swap onlyRead (policy lookup)—Organization owns the future override table; Identity keeps owning VerificationPolicyPort and every use case that calls itNone yet — table does not exist in the schema reviewedN/A — not built

Derived API (proposal) ​

Grouped by family, with the cases each route serves. Every route follows API_AND_SCHEMA_DISCIPLINE.md's task-oriented rule — no raw POST/PATCH/DELETE on a bare noun where the action carries its own invariants (re-parenting, lifecycle transitions, assignment history).

GroupRoutesCases served
Provisioning & contextPOST /organizations, GET /organizations, GET /session/contextO1, O2, O4 (O3 is a guard, not a route)
Organization profilePATCH /organization, GET /organization, GET /organization/structure, GET /sites/{id}/outletsO5, O23
Organization lifecycle (staff only)POST /platform-admin/organizations/{organizationId}/{suspension,reactivation,archival}O6
Legal entityPOST /legal-entities, PATCH /legal-entities/{id}O7, O8
Business unitPOST /business-units, PATCH /business-units/{id}, POST /business-units/{id}/verticals/{code}/{activation,deactivation}O9, O10, O11
BrandPOST /brands, PATCH /brands/{id}O12, O13
SitePOST /sites, PATCH /sites/{id}O14, O15
OutletPOST /outlets, PATCH /outlets/{id}, POST /outlets/{id}/re-parentingO16, O17, O18
Sales channelPOST /sales-channelsO19
AssignmentsPOST /outlets/{id}/legal-entity-assignments, .../business-unit-assignments, GET .../legal-entity-assignments, GET .../business-unit-assignmentsO20, O21, O24
LifecyclePOST /{aggregate}/{id}/{deactivation,reactivation} (one pair per structural aggregate)O22
Memberships & rolesGET /memberships, POST /memberships/{id}/role-assignments, PATCH /role-assignments/{id}/scope, DELETE /role-assignments/{id}, POST /memberships/{id}/{suspension,termination}O25–O29 (J1 uses O25, O28, O29)
InvitationsGET /invitations, POST /invitations, POST /invitations/{invitationId}/revocation, POST /invitations/{invitationId}/reissue, POST /invitation-previews, POST /invitation-acceptancesO25 (list), O30–O33. Acceptance and preview are addressed by token in the body, never by invitation id: the invitee does not know the id and must not be able to probe ids (D28)
Authorization (internal)no tenant route — OperationAuthorizationContract / PermissionCheckContract; staff: POST /platform-admin/plan-versions/{versionId}/capabilities, .../capabilities/{capabilityCode}/removals, .../limits, POST /platform-admin/organizations/{organizationId}/capability-overrides, POST /platform-admin/capability-overrides/{overrideId}/revocationO34
Verification policy overridenone proposed yetO35
Published reads (internal)no route — OrganizationContextContract (businessUnitStates, outletOperatingContext, shareBusinessUnit) and, for Catalog's single-statement hot reads only, the SQL functions organization.outlet_operating_context and organization.business_unit_is_operable (migration 0092; migration 0107 adds the trailing site_timezone column to outlet_operating_context so Catalog can read the local time of an outlet for the hours of its menus, S1; MODULE_CONTRACTS.md §4.4)CATALOG_OPTIONS_V2.md §6, K10a

Tenant selection contract. The coordinator's default — every tenant-scoped request carries X-Organization-Id, validated per-request against an active membership (O3) — is the contract this catalog builds against; it matches APP_SHELL_AND_ONBOARDING.md's explicit multi-tab exit criterion ("cada pestaña conserva su propia selección; el servidor valida por solicitud") without any new mutable state. The alternative, POST /organization-selections binding a "current organization" to the session server-side, would need a new mutable column (e.g., on identity.sessions) and would make two tabs on two different businesses fight over one shared session value unless the client also threads a tab-scoped override back in — which defeats the point of making it stateful in the first place. The header contract is recommended and is what every case above assumes.

Implementation plan (staging-sized groups) ​

Each group closes a self-contained slice of the matrix and is small enough to land on staging on its own (≤ ~400 changed lines per AGENTS.md).

GroupCasesWhy this boundary
0 — grantsnone directlyShipped: migration 0017_runtime_reaches_organization_platform_and_identity_rbac.sql gave ninaku_runtime the organization.* topology, platform.* reads and the identity RBAC and invitation tables (see J1 finding 1). New grants arrive with the slice that needs them.
1 — tenant context foundationO1, O2, O3, O4Everything else needs a real tenant context and a real /session/context; today's implementation is a stub.
2 — organization profile & lifecycleO5, O6Smallest write surface once context exists.
3 — flat catalog entitiesO7, O8, O9, O10, O11, O12, O13, O14, O15, O19Same shape repeated six times (register + update); no cross-entity invariants beyond per-tenant uniqueness. O11 moves into J1 as slice J1-O11, because the roles an invitation offers depend on it (D30).
4 — outlet & hierarchyO16, O17, O18The one structural aggregate with a real cycle-prevention invariant; worth its own slice for focused review.
5 — effective assignments & structure readsO20, O21, O23, O24Depends on Group 4's outlets existing; introduces the append-only/GiST-exclusion pattern for the first time.
6 — memberships & roles (G-MEMBERS)O25, O28, O29 for J1; O26, O27 after J1J1 slices J1-4 and J1-6 below, then J1-7 for assigning and re-scoping a role of an existing member.
7 — invitations (G-INVITES)O30, O31, O32, O33J1 slices J1-1, J1-1b, J1-R, J1-2, J1-3 and J1-5 below.
8 — authorization contractO34Needed once any consuming module (outside this catalog) starts calling it; can ship right after Group 6.
9 — verification policy overrideO35Lowest priority; D22 explicitly frames it as "when it arrives," not now.

J1 slices (G-MEMBERS and G-INVITES, in dependency order) ​

One aggregate per slice, one PR into staging per slice, each about 400 changed lines or less (an estimate counting SQL, TypeScript, tests and docs; the baseline/inventory regeneration is not counted; a migration and its canonical edit both count). Every slice ships its migration, its canonical SQL edit, its grants and its matrix rows together, under strict TDD. Migration numbers below are relative: staging is at migration 0049 and baseline revision 59, and every slice takes the next free number and revision when it is rebased.

J1-1 as built is too large for one PR. The branch changed about 2,370 authored lines (the migration alone is 710, its canonical copy about 250, the DB contract and concurrency tests about 1,300), not the ~350 planned. It is delivered as J1-1a and J1-1c below, and as migration 0053 (revision 63) the database layer of J1-1 lands with this branch: the outbox carries an invitation, enqueue_invitation_message and withdraw_invitation_message apply the D29 limits, the role check refuses organization.account.manage and the limit definition is seeded. J1-1b's fixes ship as their own change on top of it.

SliceDeliversSizeProof (kind → what)Adversarial cases it must include
J1-V — launch verticalsMigration: INSERT INTO platform.verticals (code, name, description) ... ON CONFLICT (code) DO NOTHING for hospitality ("Hospitality"), restaurant ("Restaurant") and retail ("Retail"), all active (D30, J1 finding 11); the same rows in 0060_platform.sql. Fixes O1 on a real database~100DB contract → the three rows exist after db:prepare with status = 'active'; prepare contract → provisioning with hospitality, with restaurant and with retail succeeds without any test-inserted verticalRe-running the seed changes nothing (idempotent); hotel is still unknown (422 · organization.provisioning_refused); ninaku_runtime cannot insert or update a vertical (42501)
J1-1a, J1-1c — invitation delivery schemaAs planned before (outbox invitation purpose, challenge_id nullable plus invitation_id, claim/purge reading either expiry, staff reissue refusing invitations, enqueue_invitation_message, withdraw_invitation_message, validate_invitation_role() refusing organization.account.manage, limit identity.pending_invitations, index on (identifier_type, normalized_value, created_at DESC), audit vocabulary, grants), split in two PRs and renumbered~400 eachDB contract → every check and function; concurrency → the per-identifier and per-organization advisory locksUnchanged from the previous plan: both or neither reference; direct insert by ninaku_runtime (42501); a destination other than the invitation's; organization B never superseding A's pending message; claim returning an invitation message with its expiry; purge of expired and revoked invitations; staff reissue refused; the 31st invitation and the second message within an hour refused; the fourth cross-organization message deferred; organization_owner refused
J1-1b — delivery fixes from the architecture review (migration 0054, revision 64; the replaces_invitation_id immutability rides along)(1) cross-organization deferral counts only other invitations' messages in pending, leased or published, and never defers past the invitation's validity: available_at = least(next free slot, expires_at - interval '1 hour'), so the cross-organization cap yields to the invitation rather than silently killing it (a refusal is not an option: it would tell organization A that someone else invited the same person, D29); (2) claim_security_outbox_messages and purge_expired_security_outbox_ciphertexts dead-letter (invitation_revoked, invitation_accepted, invitation_expired, one shared identity.invitation_closed_reason) any pending or lease-expired message whose invitation is revoked, accepted or expired, so the claim never leases it; record_security_outbox_attempt turns a retryable_error of such a message into dead_lettered (invitation_revoked / invitation_expired) instead of requeuing it; (3) the claim returns invitation_id and the organization's display name (return type changes, so DROP FUNCTION and recreate, re-grant to ninaku_worker); (4) list_security_outbox_dead_letters_as_staff left-joins challenges and invitations and reports the invitation expiry; (5) enqueue_invitation_message raises 42501 (identity_invitation_inviter_forbidden) unless identity.current_membership_id() holds organization.account.manage organization-wide~350DB contract → each fix; concurrency → revocation committed while the message is leased, then the lease expires and a second claim runsHostile organization B sends three invitations to A's invitee and revokes them: A's message is not deferred; B keeps inviting: A's message is never deferred past expires_at - 1 hour; a revoked invitation whose message is leased is never delivered after the lease expires and is never requeued by a retryable failure; an expired invitation's pending message is never claimed; staff sees an invitation dead letter that the backlog counts; a tenant session without the permission cannot enqueue
J1-R — roles by vertical (migration 0055, revision 65; built, see "J1-R as built" below)Migration: identity.roles.vertical_code + roles_vertical_fk to platform.verticals(code) + check roles_custom_without_vertical_chk (organization_id IS NULL OR vertical_code IS NULL); the seven roles of D30 (ON CONFLICT (lower(code)) WHERE organization_id IS NULL DO NOTHING), no role_permissions; new identity.role_offerable_at_scope(p_organization_id, p_role_id, p_business_unit_id, p_legal_entity_id, p_brand_id, p_site_id, p_outlet_id, p_include_descendants) (STABLE, invoker rights, so RLS still confines it to the tenant; the organization is a parameter because identity.accept_invitation calls it before the invitee has a tenant context) implementing the coverage rule; new trigger invitation_access_scopes_vertical_trg on identity.invitation_access_scopes raising 23514 identity_invitation_role_vertical_inactive; EXECUTE on the function for ninaku_runtime. Depends on J1-V~350DB contract → seed, column, check, function per scope kind; attacker contract → as ninaku_runtimeA waiter offered at a business unit whose restaurant row is inactive is refused; stock_keeper in a restaurant-only organization refused at every scope; an outlet with no effective business unit accepts only neutral roles; organization-wide accepts waiter when any business unit runs restaurant; the seed run twice leaves seven rows; none of the seven has a role_permissions row and identity.effective_permission is false for every seeded permission code for a member holding only them; ninaku_runtime cannot insert, update or delete a system role (RLS, 42501); a custom role with a vertical_code refused by the check; an unknown vertical_code refused by the FK
J1-O11 — activate or deactivate a vertical (migration 0056, revision 66; built, see "J1-O11 as built" below)O11 as revised: two routes, use case, repository, audit vocabulary migration (organization.business_unit.vertical_activated, .vertical_deactivated), codes organization.vertical_unavailable and organization.last_vertical_required~350Unit → use case order and idempotency; integration → repository against PostgreSQL (RLS, triggers); e2e → codes and audit recordsMember without the permission (403, audited); foreign or unknown business unit (404); unknown, deprecated or retired vertical, inactive business unit (422); deactivating the last active vertical (409); two concurrent deactivations of the last two verticals leave one; activation repeated and raced (200, one row); reactivation keeps enabled_at
J1-2a — offerable roles read (no migration, revision stays 66; built, see "J1-2a as built" below)GET /offerable-roles (O30) over identity.role_offerable_at_scope~250Integration → read model against PostgreSQL per scope kind; e2e → codesNever lists organization_owner, a retired role or another tenant's custom role; foreign scope id (404); scopeId with scopeKind=organization (400); a cashier caller (403); the list and the J1-R trigger agree for every seeded role and scope kind
J1-W — the worker delivers invitationsinvitation in security-outbox-purpose.ts; ClaimedSecurityOutboxMessage with challengeId nullable and invitationId, mapped in security-outbox-dispatch.repository.ts (today a non-challenge row has no challenge_id and an invitation purpose is mapped as malformed and dead-lettered); copy in security-outbox-message-copy.ts (organization name, link with the token in the fragment, expiry in days; roles are shown by the preview, not the message); invitation link base URL in configuration. Ships before J1-2, so no enqueued invitation meets an old worker. As built: the claimed message is a union, ClaimedChallengeSecurityOutboxMessage (challengeId) or ClaimedInvitationSecurityOutboxMessage (invitationId, organizationName), chosen and validated by the row mapper (a row with neither, with both, or an invitation without its organization name is dead-lettered as malformed_claim_row); the claim's expiry column is read as expiresAt for both kinds. The copy is security-outbox-invitation-copy.ts, English like the four code messages; its subject, text and link are shown in the J1-W invitation message after this table (the remaining time is rounded down, the WhatsApp text is identical without the subject, and the token is percent-encoded in the fragment). No application URL existed, so the worker configuration gained INVITATION_LINK_BASE_URL (an absolute origin with no path, https or local http, validated whenever present; OPTIONAL in every environment, because a required variable would stop every OTP delivery when it is missing; declared on the worker service in .railway/railway.ts as preserve()). When it is absent, development and test fall back to http://localhost:3000, while staging and production log one startup warning naming the variable and treat every invitation message as a retryable failure with the class invitation_link_unconfigured (the token is neither opened nor sent; the normal bounded backoff applies and the message is dead-lettered after the last attempt). The token is opened by the existing OTP opener at send time, lives only in the string passed to the sender, is zeroed after the send and is never logged (the failure logs carry the failure class and the cause name, never a message). The plaintext wipe in SQL is identity.record_security_outbox_attempt (published and failed null the ciphertext, nonce and key version); a claim that returns nothing sends nothing; a retryable failure of a message whose invitation closed is turned into a dead letter by that same function, which the worker cannot observe (its tally counts it as retried). failure class invitation_expired is the worker's own check. Staff: isSecurityOutboxPurpose and the dead-letter response enum accept invitation (OpenAPI major version 4.0.0 because the response enum widened) and the reissue refusal invitation_message maps security_outbox_reissue_invitation_message~300Unit → mapping and copy; worker integration → a claimed invitation message is sent by email and by WhatsApp and its ciphertext is wipedA row with neither challengeId nor invitationId is dead-lettered as malformed; the token never appears in a log line; an invitation message whose invitation was revoked before the claim is never sent (J1-1b)
J1-2 — invite (O30) (built, see "J1-2 as built" below)InvitationDeliveryPort (schedule, withdraw) and its outbox adapter; POST /invitations~400Unit → use case order (permission, roles, vertical rule, member check, open-invitation branch, schedule, audit); integration → repository and adapter against PostgreSQL; e2e → HTTP codesEvery O30 row, including: a manager inviting (403, audited); organization_owner offered (403); stock_keeper offered in a restaurant (422 · identity.invitation_role_vertical_inactive); retired role (422); foreign role and scope (422); already-member (409); non-member and unknown identifier giving byte-identical 201 bodies; different configuration while open (409); exact retry (200, one message); concurrent identical requests (one row, one message); expired-open auto-revoked; pending cap (409); a rollback after schedule leaves no message; token absent from every response, log line and audit record
J1-3 — accept (O32) (built, see "J1-3 as built" below)identity.preview_invitation, identity.accept_invitation (SECURITY DEFINER, audit inside, re-checks privilege and the vertical rule); POST /invitation-previews, POST /invitation-acceptances~400DB contract → acceptance function (copy, audit, recipient check, seat trigger, vertical re-check); concurrency → last seat raced by two invitees, revoke racing accept; e2e → the full journey for an invitee with an identity, and for one without (sign-up, confirm, accept), ending with the new member selecting the organization (O3) and no second organization existingUnknown and guessed tokens identical 404; closed (409); mismatched or unverified identifier (403) without revealing the owner; already a member (409); retry after success (201, same membership); seat full (409, invitation still open); organization suspended (409); deactivated role (422); vertical deactivated after sending (422 · identity.invitation_role_vertical_inactive); Throttler (429)
J1-4 — who is in (O25) (migration 0059, revision 69; built, see "J1-4 as built" below)GET /memberships with meta.seats, GET /invitations, identity.invitation_delivery_states~300Integration → read models, RLS isolation, keyset cursor; e2e → codes and the seat numbers after J1-3Cashier listing (403); another tenant's members never returned, including with a forged cursor; tampered cursor (400); token_hash never in the body; seatLimit null when unlimited; each assignment shows its role code and verticalCode
J1-5 — revoke and reissue (O31, O33) (migration 0060, revision 70; built, see "J1-5 as built" below)invitations.replaces_invitation_id (the column existed since migration 0053; 0060 adds its unique index and the audit action); POST /invitations/{invitationId}/revocation, POST /invitations/{invitationId}/reissue, both through InvitationDeliveryPort~350DB contract → new column and its foreign key; integration → revoke/reissue repository; concurrency → reissue racing accept, two reissues racing; e2e → codes and the old link refused after reissueRevoke accepted (409); revoke twice (200, one audit record); revoked invitation's pending message withdrawn; reissue accepted or revoked (409); reissue retry (201, same replacement, one message); reissue inside the cooldown (429, nothing revoked); reissue whose vertical was deactivated (422, nothing revoked); old token answers 409 · identity.invitation_closed
J1-6 — control (O28, O29) (migration 0061, revision 71; built, see "J1-6 as built" below)DELETE /role-assignments/{roleAssignmentId}, POST /memberships/{membershipId}/suspension, POST /memberships/{membershipId}/reactivation, POST /memberships/{membershipId}/termination (one aggregate: membership authority; the audit vocabulary is the only SQL)~400Integration → D21 paths; concurrency → two administrators removed at once (identity.lock_administration_tenant); e2e → codes, the seat freed by a suspension lets a waiting acceptance throughLast organization-wide administrator (23514 · identity_last_administrator_required, answered 409 · identity.last_administrator_required); reactivation over the seat limit (409 · identity.seat_limit_reached); a member suspending themselves while last administrator; tenant audit records for each change
J1-7 — assign and re-scope a role (O26, O27) (migration 0062, revision 72; built, see "J1-7 as built" below)POST /memberships/{membershipId}/role-assignments, PATCH /role-assignments/{roleAssignmentId}/scope; the trigger access_scopes_vertical_trg and the audit actions identity.role_assignment.created and identity.role_assignment.scope_changed~700DB contract → vertical backstop, D21 on a scope change, cross-tenant; concurrency → vertical deactivation and two administrators narrowing each other; integration → repository; e2e → codes, the journeyAn administrator role never given or widened over HTTP; a vertical role at a unit without its vertical; the last administrator narrowing their own scope; two administrators narrowing each other; a suspended member; another organization's membership, assignment, role or scope target

The J1-W invitation message ​

The placeholders in angle brackets are filled at send time; the expiry is one of N days, 1 day or less than a day.

text
Subject: <organization> invited you to join Ninaku

<organization> invited you to join their team on Ninaku. Open this link to accept: <link> The invitation expires in <expiry>.

Link: <INVITATION_LINK_BASE_URL>/join#token=<token>

J1-R as built. The slice is the database layer only. The column and the check live in 0040_identity.sql; the foreign key and the seven-role seed live in 0060_platform.sql right after the vertical seed (a 0040 foreign key to platform.verticals cannot exist before the platform schema); the coverage function, the scope trigger and the delete guard live in 0400_cross_domain_contracts.sql, where the other cross-domain contracts sit. identity.role_offerable_at_scope is plpgsql with invoker rights, and ninaku_runtime holds EXECUTE on it (runtime_grants.sql, checked by the prepare contract), because the scope trigger runs as the caller. Two things go beyond the plan row: the trigger takes FOR SHARE locks on the organization's active rows of the role's vertical, in bv.id order (the same key as the deactivation of J1-O11), before it decides, so a deactivation that commits first refuses the offer and one that arrives later waits for it (an invitation can still outlive a later deactivation, which the acceptance re-check of J1-3 catches); and roles_system_role_protect_trg refuses DELETE of any role with a null organization_id (23514 · identity_system_role_undeletable), as D30 says a system role is only ever inactive or archived. The trigger names identity_invitation_role_vertical_inactive for every refusal of the function (inactive role, foreign role, administrative role), not only a vertical one; the earlier triggers still refuse those first with their own names. Not proven here: ninaku_runtime inserting, updating or deleting a system role (the row stays a gap).

J1-O11 as built. One use case, ChangeBusinessUnitVerticalUseCase (activate and deactivate, sharing one permission-then-lock preamble so the two commands do not duplicate it), its repository and port, the two routes in BusinessUnitVerticalsController, the two failures and migration 0056 (audit vocabulary only; the table, its trigger, its policy and its runtime grants already existed). The activation answers 201 for a new row and 200 otherwise; the response is { businessUnitId, verticalCode, status }. Beyond the plan row: the last-vertical rule is per organization; the audit change is one attribute per vertical, organization.business_unit.vertical.<code>, with before and after status; a deactivation on an inactive business unit is refused like an activation; the lock on the organization's active rows is taken only when a real deactivation is about to happen. Interaction with J1-R: the invitation_access_scopes_vertical_trg trigger took FOR SHARE OF bv without an ORDER BY while a deactivation locks in id order; with the heap order of the rows the reverse of the id order, an organization-wide offer and a deactivation deadlocked (40P01, observed on the first round against the first version of migration 0055). The trigger now locks ORDER BY bv.id, the same global key as the deactivation, and the test NINAKU_ROLES_BY_VERTICAL_CONCURRENCY_OK: an organization-wide offer and the id-ordered deactivation lock never deadlock when the heap order is the reverse of the id order passes 20 rounds with both sessions completing; removing the ORDER BY from the function makes it fail again. The migration is named 0056_business_unit_vertical_audit_actions. Not proven here: that a deactivation leaves the role assignments of the vertical's roles untouched (the row stays a gap: O26 exists since J1-7, but no test deactivates a vertical under a member who holds its role yet).

K10c as built (no migration). Organization no longer names a Platform table: ChangeBusinessUnitVerticalUseCase keeps the permission, the unit lock, the unit status check and the audit record, and calls BusinessUnitVerticalsContract.activate and .deactivate inside the same tenant transaction. Platform (ChangeBusinessUnitVerticalStatusUseCase and PlatformBusinessUnitVerticalsRepository) owns the catalog check, the status read, the insert or update, the organization advisory lock and the FOR UPDATE of the active rows in id order, now filtered by organization.business_unit_is_operable(...) instead of a join on organization.business_units, and every statement names the organization in context explicitly. The contract answers an outcome (changed, unchanged, unavailable, last_active_vertical) and the previous status, and Organization turns the refusals into the same 422 and 409 as before. The statements per request are the same on every success path; an activation on an inactive or archived unit is refused before Platform reads the catalog, one statement fewer.

J1-2a as built. No SQL: the repository selects from identity.roles with identity.role_offerable_at_scope(...) in the WHERE clause, so the offering rule exists only in the database and the list cannot disagree with the invitation trigger (ORDER BY name, id). The route lives in the Organization module, not in Identity as the J1 plan row implies: the failure catalogue of Identity is tested to carry only identity.* codes, and the read is a tenant-scoped read of the organization's own structure, so it answers the codes of the structure routes (403 · organization.action_forbidden, not identity.action_forbidden; the invite route of J1-2 keeps the Identity codes the plan names). Query: scopeKind (organization, business_unit, legal_entity, brand, site, outlet), scopeId (a UUID, forbidden for organization, required for the others) and includeDescendants (true or false, default false, accepted as true only for an outlet, like the access_scopes_descendants_chk constraint); anything else is 400. The use case checks organization.account.manage organization-wide first (no audit record: a read), then the existence of the target, which RLS confines to the caller's organization (a foreign id and an unknown id are the same 404, and a caller without the permission is refused before the lookup, so the answer reveals nothing about the scope). The permission check is not mayManageOrganization, which audits the denial; reads write nothing. A business unit that exists but is inactive is not refused: it lists only neutral roles, because the SQL function treats it as running no vertical. The answer is 200 { data: [ { roleId, code, name, verticalCode } ], meta }, Cache-Control: private, no-cache, no ETag (turning a vertical on or off changes the list without changing any row of the roles, so no version or hash of the data the roles come from would be a safe validator) and no pagination (the catalog of one organization is at most a few dozen roles). Another organization's role never reaches the list through two layers: the identity.roles select policy hides it, and the function's own organization clause refuses it; the proof removes both together, because removing one alone is absorbed by the other.

J1-2 as built. Migration 0057 (revision 67) seeds only the audit vocabulary (the resource kind identity.invitation and the actions identity.invitation.created and identity.invitation.revoked, owner identity, tenant scope, retention class administration.platform); the writes are plain SQL from the repository, because ninaku_runtime already held the table grants and EXECUTE on identity.enqueue_invitation_message and identity.withdraw_invitation_message, and no new function was needed. The use case is InvitePersonUseCase in the Identity module, with InvitationRepositoryPort, InvitationDeliveryPort (schedule, withdraw; adapter SecurityOutboxInvitationDeliveryService) and InvitationTokenPort (32 random bytes, base64url; token_hash = sha256 of that string, the form J1-3 will hash again). The body is the one of the O30 main path (roles, one to five items, each roleId plus scope; the scope carries id and, for an outlet only, includeDescendants); a phone must be an international E.164 number, because there is no country field. Natural idempotency, as O30 says (the neighbouring structure commands do not take an Idempotency-Key either): the same configuration answers 200 with the open invitation and no second message, a different one answers 409 · identity.invitation_already_open. Beyond the plan row: (1) before it reads the open invitation the repository takes the advisory lock identity.invitation_open:<organization>:<type>:<normalized value>, so two identical requests never reach the unique index (the second waits, then answers 200); (2) the vertical rule is enforced only by the J1-R trigger, so the member check and the open-invitation branch run before it and a refusal rolls the whole transaction back, including a revocation of an expired invitation made earlier in it; (3) the privilege check is audited as a denial whose target is the id the invitation would have had; (4) the revocation of an expired invitation also calls withdraw and is audited with reason expired_replaced; (5) the audit of the creation carries the identifier type, the masked identifier (from the normalized value, so a retry masks alike), invitation.role_codes, one invitation.scope.<role code> per role (organization, <kind>:<id> or <kind>:<id>:descendants) and the expiry; (6) every role refusal that is not a privilege refusal (unknown, retired or foreign role) and every scope target that is unknown, inactive or foreign answer 422 · identity.invitation_refused with one detail, because Identity cannot emit the organization.structure_not_found of the structure routes. The 429 · identity.invitation_rate_limited carries Retry-After from the retry_after_seconds hint of the per-address hour limit; the organization daily cap carried no hint when this slice shipped and gained one in J1-3 (migration 0058, see "J1-3 as built"). PublicFailureError gained an optional retryAfterSeconds for this, and the failure catalogue test now allows retryable: true for a 429. INVITES_A_PERSON_RATE_LIMIT (20 requests per 10 minutes per IP) guards the route. A message deferred by the cross-organization cap answers the same 201 and only carries a later available_at (proven over HTTP). Not covered by a test: the trace attributes (no code path sets one) and response-time equality for the no-enumeration rule.

J1-3 as built. Migration 0058 (revision 68) adds the audit vocabulary (the resource kind identity.membership and the action identity.invitation.accepted, tenant scope, owner identity), the two functions and a fix of J1-2: the daily organization cap of identity.enqueue_invitation_message now raises with retry_after_seconds=<seconds until the oldest invitation of the window leaves it, at least 1>, and invitation-failure.ts reads it, so that 429 carries Retry-After like the per-address one. Routes: POST /invitation-previews answers 200 ({ organizationName, identifierType, identifier (masked), expiresAt, callerOwnsIdentifier, roles: [{ code, name, scope: { kind, name, includeDescendants } }] }) and POST /invitation-acceptances answers 201 ({ organizationId, organizationName, membershipId, roleCodes }, the same for a replay). Both take { token } strictly (43 base64url characters), both use ANSWERS_A_CHALLENGE_RATE_LIMIT, neither takes an invitation id or an X-Organization-Id. Failures: 404 · identity.invitation_not_found, 409 · identity.invitation_closed, 403 · identity.invitation_recipient_mismatch, 409 · identity.invitee_already_member, 409 · identity.seat_limit_reached, 422 · identity.invitation_refused and 422 · identity.invitation_role_vertical_inactive, each from a named constraint of the function, mapped only in invitation-acceptance-failure.ts. What the preview reveals, and to whom: only to the holder of the token, the organization name, each offered role with its name and the kind and name of its scope, the expiry, the masked identifier (masked in the use case) and callerOwnsIdentifier, which is about the caller only (false with no session, with another identity or with the identifier unverified), so it never says whether someone else owns the identifier; a closed invitation (expired, revoked, accepted, or its organization not active) answers one row with no detail and the use case 409; an unknown hash answers no row and 404. The preview function is STABLE, so the database itself refuses a write inside it. A new OptionalSessionGuard (src/foundation/auth) lets the preview work with or without a session: no Authorization header means nobody, but a header that is present and not a live bearer session is 401 · identity.session_required, never taken for nobody. The acceptance is one call of identity.accept_invitation inside one system transaction (SystemTransactionRunnerService, the caller sets app.identity_id and app.actor_identity_id, the function reads identity.current_identity_id() and has no identity parameter). Order inside the function: lock the invitation by token_hash FOR UPDATE (unknown: identity_invitation_not_found); an invitation accepted by the same caller answers the membership whose joined_at equals accepted_at (the membership is created with joined_at = accepted_at = statement_timestamp(), which is how a replay finds it) and writes nothing; accepted by anyone else, revoked, expired or of an inactive organization: identity_invitation_closed; caller not an active human owning the verified, non-revoked identifier: identity_invitation_recipient_mismatch (42501); a current membership of the caller: identity_invitee_already_member; then, for every offered role, status active, global or of the organization and not granting organization.account.manage (identity_invitation_role_unavailable, mapped to 422 · identity.invitation_refused) and identity.role_offerable_at_scope at each of its scopes (identity_invitation_role_vertical_inactive), after taking FOR SHARE on the vertical rows in bv.id order like the J1-R trigger. Only then the membership, the seat check, the role assignments and scopes (assigned_by and granted_by the inviter's identity when that membership still exists), the accepted_at update (which still fires the recipient trigger) and the audit record. Nothing is written for a refusal, so every refusal leaves the invitation open, and the use case needs no compensation. The tenant audit record is written with the four request settings swapped to the new membership and restored from the values read at entry, exactly as platform.record_organization_provisioned does; the record carries the invitation and the membership as targets and never the token or its hash. The seat limit is judged by identity.assert_membership_seat_available, the same function the deferred memberships_seat_limit_trg calls, which the function now calls itself right after inserting the membership: the trigger would raise at COMMIT, outside the repository's try, where no constraint mapping can catch it; with the call inside the function the refusal is raised (identity_membership_seat_limit, 409 · identity.seat_limit_reached) with the invitation untouched, and the trigger stays as the fence at commit. The refusal for a role that is no longer offerable is the one this document already listed for O32 (422, invitation left open for the owner to revoke). Differences from the plan row: a malformed token is 400, not 404 (the shape of a token is public and says nothing about any invitation); the response of the acceptance carries the organization name and the role codes besides the two ids; the preview answers 200. Not covered by a test: an archived organization separately from a suspended one (one status check), the phone path of the preview and the acceptance (only email is exercised over HTTP), response-time equality for the unknown and closed answers, and the OPENAPI_DOCUMENT_VERSION stays 4.0.0 because both routes are additive.

J1-4 as built. Migration 0059 (revision 69) adds the function identity.invitation_delivery_states(uuid[]) and the index invitation_access_scopes_role_assignment_idx on identity.invitation_access_scopes (invitation_role_assignment_id). The function is STABLE, SECURITY DEFINER, pins search_path = pg_catalog, pg_temp with row_security = off, returns only (delivery_invitation_id, delivery_publication_state) of the newest message (created_at DESC, id DESC) of each invitation it is given, and does so only when identity.has_active_tenant_context() and only for invitations whose organization_id is identity.current_organization_id(), because with row security off the function has to be the tenant fence itself; EXECUTE goes to ninaku_runtime and to nobody else. The index exists because EXPLAIN (ANALYZE, BUFFERS) over 2,500 invitations of one organization (6,500 in the table) showed a Seq Scan of invitation_access_scopes for every invitation of the page (14.6 ms for 50 invitations) and shows an Index Scan with it (0.4 ms); the partial unique indexes of that table cannot serve the join. Routes: GET /memberships and GET /invitations (Identity, tag Memberships and Invitations) with X-Organization-Id, SessionGuard and TenantContextGuard, permission organization.account.manage checked organization-wide inside the tenant transaction, so a refusal is 403 · identity.action_forbidden (the new MemberDirectoryForbiddenError, mapped beside the invitation one), nothing is audited, and the answer carries Cache-Control: private, no-cache and no ETag. The cursor binding is the route, the organization and the filter, so the cursor of another organization or of another status or state is 400 · collection.cursor_incompatible. Seats: meta.seats.used is one count(*) of the memberships of the organization with status = 'active', the same set identity.assert_membership_seat_available counts (a suspended or an ended membership does not count), and limit is platform.effective_limit(org, 'identity.active_memberships') read in the same statement, null when it is unlimited or the limit was never defined. A members page is four statements (the page, the role assignments with their scopes and roles, the identifiers, the seats) and an invitations page three (the page, the offered roles, the delivery function). The member query repeats organization_id = $1 although the policy also confines it: memberships_select_policy lets a caller read their own memberships of other organizations, so without the filter a caller who belongs to two organizations would see their other membership in this list (a test pins it). Differences from the plan row and from the text of O25 above: the identifier of an invitation is masked, not "as stored"; the default invitation filter is open,expired and the filter takes one state; deliveryState has the four values above instead of the raw outbox states; a role assignment shows code, name and verticalCode (the plan row's code and verticalCode) and its roleAssignmentId, which J1-6 needs; the member's identifiers are shown as stored, to an administrator of the organization that person belongs to. No index was added to identity.memberships for the keyset: with the policy's OR the planner reads the organization's rows with a bitmap scan and a top-N sort (1.5 to 3 ms for 2,500 members, no sequential scan), and a (organization_id, joined_at DESC, id DESC) index tried in the same measurement did not remove the sort. ApiCollectionResponse gained an optional meta argument to document meta.seats, and the private, no-cache header value became the shared TENANT_CACHE_CONTROL. Not proven: the phone identifier of a member over HTTP, and the latency of the two routes under the SLA percentiles (they are administrative and are not in perf:http). OPENAPI_DOCUMENT_VERSION stays 4.0.0 because both routes are additive.

J1-5 as built. Migration 0060 (revision 70) adds the unique partial index invitations_replaces_invitation_uq on identity.invitations (replaces_invitation_id) WHERE replaces_invitation_id IS NOT NULL and seeds the audit action identity.invitation.reissued (tenant scope, owner identity, before and after allowed). The column and its same-organization foreign key already existed (0053), so the plan row's "migration" is only the index: an invitation is replaced at most once (a second replacement is refused even for another address, 23505 · invitations_replaces_invitation_uq, which the repository maps to 409 · identity.invitation_closed) and the replacement is found by an index instead of a scan of a table that grows without limit. No function was needed: identity.withdraw_invitation_message and identity.enqueue_invitation_message already served the runtime. The description of the action identity.invitation.revoked still says "revoked because it expired and a new one replaces it", because audit.action_types has an update guard; the reason code tells the three causes apart (expired_replaced, revoked_by_member, reissued). Routes (Identity, tag Invitations, X-Organization-Id, SessionGuard, TenantContextGuard, the per-IP INVITES_A_PERSON_RATE_LIMIT, permission organization.account.manage organization-wide, a refusal audited as denied): POST /invitations/{invitationId}/revocation answers 200 with { invitationId, identifierType, identifier (masked), roles, status: "revoked", expiresAt } and takes no body; POST /invitations/{invitationId}/reissue takes an optional strict body { expiresInDays } (1 to 30, default 7) and answers 201 with the shape of POST /invitations (status: "pending"). Codes: 400 · foundation.validation_failed (id not a UUID, bad body), 401, 403 · tenant.context_rejected, 403 · identity.action_forbidden, 404 · identity.invitation_not_found (unknown id and another organization's id, the same answer, because the row policy hides the foreign row), 409 · identity.invitation_closed (accepted; for a reissue also revoked without an open replacement), 429 · identity.invitation_rate_limited with Retry-After, 422 and 403 of the offer at reissue, and 409 · identity.invitation_pending_limit_reached as at invitation. Locking: both routes call lockById, which reads the identifier of the row, takes the same advisory lock the invite flow takes on (organization, type, address) and then SELECT ... FOR UPDATE the row, in that order, so invite, revoke and reissue of one address never deadlock and serialise. identity.accept_invitation locks the same row FOR UPDATE; under READ COMMITTED the second locker re-reads the row after the first commits, so a revoke and an acceptance racing end with exactly one winner: if the revoke commits first the acceptance answers 409 · identity.invitation_closed; if the acceptance commits first the revoke reads accepted_at and answers 409 without writing, and the membership exists. Two administrators revoking at once change the row once (the second reads it revoked, answers the same 200 and writes and audits nothing); two reissuing at once give both 201 with the same replacement, because the second reads the old row revoked and finds the replacement by replaces_invitation_id. Revoke: lock, refuse accepted, no-op if revoked, else revoked_at = now(), withdraw the message and audit with reason revoked_by_member. Reissue, in one transaction: lock, refuse accepted, answer the open replacement if the old row is revoked (else 409), re-check the offer (examineOffers, the same function the invite flow uses: role active, not administrative, scope targets active), revoke the old row, withdraw its message and audit it (reason reissued), insert the new row with the same roles and scopes, a new 32-byte secret (hash only), replaces_invitation_id and an expiry counted from the reissue, seal and enqueue the secret through InvitationDeliveryPort and audit identity.invitation.reissued. The D29 limits apply unchanged because the same identity.enqueue_invitation_message runs: the cooldown counts the old message (dead-lettered messages still count), so a reissue within an hour of the previous message is 429 and the whole transaction rolls back (nothing revoked, no audit); a vertical turned off since is refused by the J1-R trigger at the insert with the same rollback. A retry of a reissue is naturally idempotent through the link (201 with the existing replacement while it is open; if it was closed since, 409). The old outbox message is dead-lettered as invitation_revoked with the ciphertext wiped (a leased message is closed by the claim rules of 0054); the new one is pending. Shared code: invitation-events.ts (permission, audit helpers), invitation-offers.ts, issue-invitation.ts and revoke-open-invitation.ts are used by the invite, revoke and reissue use cases; the J1-2 wiring moved to invitation-sending.providers.ts. Not proven: the hourly cooldown by waiting an hour (the message is aged by a SQL fixture, backdateMessage, because no route can age a message), a role deactivation or archival (no route exists, so UPDATE identity.roles is the fixture), the accepted state of an invitation in the race test is reached through the real acceptance route, and a leased message of a revoked invitation over HTTP (covered at the database by the 0054 contracts). OPENAPI_DOCUMENT_VERSION stays 4.0.0 because both routes are additive.

J1-6 as built. Migration 0061 (revision 71) only seeds the audit vocabulary: the resource kind identity.role_assignment and the tenant action types identity.membership.suspended, identity.membership.reactivated, identity.membership.ended and identity.role_assignment.revoked (owner identity, administration, medium risk, before and after allowed). There is no new function and no new grant: ninaku_runtime already updated identity.memberships and deleted identity.role_assignments and identity.access_scopes, and the triggers that protect the last administrator (D21) and the seat limit already existed. Routes (Identity, tags Memberships, X-Organization-Id, SessionGuard, TenantContextGuard, organization-wide organization.account.manage, a refusal audited as denied with the reason permission_missing): POST /memberships/{membershipId}/suspension, /reactivation and /termination take no body and answer 200 with { membershipId, status, suspendedAt, endedAt }; DELETE /role-assignments/{roleAssignmentId} answers 200 with { roleAssignmentId, membershipId, roleCode }. Codes: 400 · foundation.validation_failed (id not a UUID), 401 · identity.session_required, 403 · tenant.context_rejected, 403 · identity.action_forbidden, 404 · identity.membership_not_found or identity.role_assignment_not_found (unknown and of another organization are indistinguishable), 409 · identity.last_administrator_required (new), 409 · identity.seat_limit_reached (reactivation over the limit; its text no longer speaks only of invitations), 409 · identity.membership_ended (new: ending is final, so suspending or reactivating an ended membership is refused; the person comes back through a new invitation, which creates a new membership, proven through the real flow) and, for a member removed while their own request was running, the tenant refusal. A state that already is the requested one answers 200 and writes nothing and leaves no second audit record; a revoked role assignment is gone, so repeating the call answers 404.

The last administrator (D21). The rule is the database's: nobody can revoke the last organization-wide organization.account.manage assignment, or suspend or end the last active member holding it, caller included; a suspended or ended administrator does not count. The repository answers it at the moment of the change: the audit record is written first, then SET CONSTRAINTS ALL IMMEDIATE runs the deferred checks (the audit completeness check must run while the caller is still an active member, or a member removing themself would make their own audit record invisible to it) and the change follows, so the refusal is raised inside the repository call and mapped by constraint name (identity_last_administrator_required, identity_membership_seat_limit) there and nowhere else. Two administrators removing each other at the same moment leave exactly one: the use case takes the per-organization administration lock (pg_advisory_xact_lock on identity.admin.tenant:<organization>, the very key identity.lock_administration_tenant takes in the triggers) before it reads anything, and re-asserts the tenant context under it, so the second request waits, finds itself removed and gets the tenant refusal, or finds itself still an administrator and meets the database's check. The race is proven at the database (member-control-concurrency.ts), at the repository (two transactions, one held until the other waits) and over HTTP (three rounds, exactly one 200). A request from a member whose membership ended while it was in flight may therefore answer 403 · tenant.context_rejected or 404 instead of 409; exactly one request wins in every case.

What a removed member keeps. The tenant context is read from the membership on every request (ResolveTenantContextUseCase and the row-level security function identity.has_active_tenant_context), so a suspended or ended member is refused at their very next request with the same 403 · tenant.context_rejected a stranger gets, and GET /session/context shows no selected organization. Their sessions and tokens are not revoked: the person still has an account and may belong to other organizations. Suspension keeps the roles and the seat is free; reactivation takes a seat again (the deferred seat check, identity_membership_seat_limit, now settled inside the repository), so a full team refuses it until a seat frees.

Not proven or not built. The second administrator of the tests is a SQL fixture (assignSystemRole): no route promotes a member and an invitation cannot offer an administrative role by design (O26, built later in J1-7, refuses it too). The seat limit of the full-team tests is a SQL override (limitSeats): no route changes a plan limit. The members without a role of the access tests are SQL fixtures too (aMemberWithoutRole), as in the J1-5 tests. Repository integration specs seed their people with SQL by nature; the e2e specs and the journey use only the routes (provisioning, sign-up and verification, invitation, acceptance, listing, revocation, suspension, reactivation, termination, vertical activation). The re-invitation of an ended member waits an hour for the same address, so the earlier message is aged by backdateMessage. OPENAPI_DOCUMENT_VERSION stays 4.0.0: the three paths are additive.

The journey is complete. After J1-6 the owner of a new restaurant can provision the organization, see the roles they may offer, invite a manager, a waiter and a cashier, bring in the one who has no account, read the team, the invitations and the seats, revoke a wrong invitation and invite the right address, suspend a member and see the seat freed, end a membership and see that person lose access, take a role away, and activate another vertical and see its roles appear; members-control-journey.e2e-spec.ts walks all of it over HTTP and checks what the owner sees at every step. O26 and O27 were built afterwards as J1-7.

J1-7 as built. Migration 0062 (revision 72) adds the database backstop of the vertical rule and the audit vocabulary. The function identity.validate_access_scope_vertical and the trigger access_scopes_vertical_trg on identity.access_scopes refuse, with identity_role_assignment_vertical_inactive, a role whose vertical_code is not null at a scope where identity.role_offerable_at_scope says its vertical is active in no covered business unit, as invitation_access_scopes_vertical_trg already did for invitations. A role with no vertical, such as the administrator role stored by provisioning, passes unchanged, which is why the trigger checks the vertical column and does not call the coverage function for every role (the function also refuses administrative roles, which provisioning stores). The vocabulary gains the tenant action types identity.role_assignment.created and identity.role_assignment.scope_changed. No new grant: ninaku_runtime already inserted into identity.role_assignments and identity.access_scopes and deleted from identity.access_scopes, and the row-level security policies already use the scalar subquery form. No new SECURITY DEFINER function: the changes are plain statements under row-level security, like J1-6.

Routes (Identity, tag Memberships, X-Organization-Id, SessionGuard, TenantContextGuard, organization-wide organization.account.manage, a refusal audited as denied with the reason permission_missing). POST /memberships/{membershipId}/role-assignments takes { roleId, scope }, the scope shaped exactly like the one of an invitation (organization, business_unit, legal_entity, brand, site or outlet with includeDescendants), and answers 201, or 200 for the same role at the same scope (nothing written, no second audit record). PATCH /role-assignments/{roleAssignmentId}/scope takes { scope } and answers 200; the same scope again is 200 with nothing written. Codes: 400 · foundation.validation_failed, 401 · identity.session_required, 403 · tenant.context_rejected, 403 · identity.action_forbidden, 403 · identity.role_assignment_privilege_exceeded (new), 404 · identity.membership_not_found or identity.role_assignment_not_found (unknown and of another organization are indistinguishable), 409 · identity.role_already_assigned (new: the same role at another scope; change its scope instead), 409 · identity.membership_not_active (new: the member is suspended or ended), 409 · identity.last_administrator_required, 422 · identity.role_assignment_refused (new: unknown, retired or foreign role or scope target, all alike) and 422 · identity.role_assignment_vertical_inactive (new).

Decisions where the text was silent. The order is the one of J1-6: the per-organization administration lock first, then the permission, then the checks, then the audit record and the change. A refusal for a role that grants administration is audited as denied with the reason privilege_exceeded, as in the invitation. O27 applies the privilege rule only when it would widen an administrator role to the whole organization: narrowing an administrator is allowed (D21 guards the last one) and a role already at the scope asked answers 200 before the rule is read, so an administrator is never refused for the scope they have. The role of a suspended or ended member cannot be re-scoped (409 · identity.membership_not_active), consistent with O26. The vertical rule is checked twice, on purpose: the use case asks identity.role_offerable_at_scope for roles with a vertical (so the answer is a clean 422) and the trigger is the backstop for any other writer. The audit record is written before the change, in the same transaction, so a refused change leaves no record of a change.

The scope change (O27). The repository settles the deferred checks, holds them, deletes the scope row, inserts the new one and settles again, in one transaction, never an in-place update, so the deferred role_assignments_scoped_trg and D21 (identity_last_administrator_required) fail inside the call and are mapped by constraint name there and nowhere else. Two administrators narrowing each other at the same moment leave exactly one: the use case takes the same lock the triggers take (identity.admin.tenant:<organization>), so the second request waits and then is refused either by the permission check (it was narrowed) or by the database. The race is proven at the database (role-assignment-concurrency.ts), at the repository (two transactions, one held) and over HTTP.

Not proven or not built. The administrator of the demotion test, the second administrator of the race test, the members without a role and the member who administers a single business unit are SQL fixtures (grantManageRole, assignSystemRole, aMemberWithoutRole): no route creates an administrator or a scoped administrator by design. The repository integration specs seed their people with SQL by nature (addTeamMember); the e2e specs and the journey use only routes (provisioning, sign-up, invitation, acceptance, listing, revocation, suspension, termination, business unit, site and vertical activation), apart from the two fixtures named. A vertical role at a site, brand, legal entity or outlet cannot be given over HTTP yet because no route opens an outlet, so the vertical rule is proven over HTTP at the whole organization and at a business unit, and at the other kinds in the database contract. Over HTTP the effect of a changed scope on access is visible only for the one permission that exists, organization.account.manage: the seven system roles have no permission, so narrowing a cashier changes what the member list shows, not what the cashier can do. identity.effective_permission does not check that the scoped site, brand, business unit, legal entity or outlet is active, so a role scoped to a deactivated entity would still grant access; the matrix records it as a gap to decide when O22 is built. OPENAPI_DOCUMENT_VERSION stays 4.0.0: the two paths are additive.

Order: J1-V ships first and alone (it repairs O1). J1-1a → J1-1c → J1-1b; J1-V → J1-R; J1-O11 needs nothing new (its tests seed verticals) but must merge before J1-2a. J1-2a needs J1-R and J1-O11; J1-W needs J1-1b; J1-2 needs J1-2a and J1-W. J1-2 blocks J1-3, J1-4 and J1-5; J1-6 needs only J1-3 (a second member to act on). The journey is usable by the owner after J1-4 and complete after J1-6; J1-7 follows it.

Application Foundation in progress. Tracked in issue #13.