---
url: /reference/ORGANIZATION_USE_CASES.md
---
# 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`](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](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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /organizations` | Command | — | Organization owns the route; calls `platform.provision_organization_for_caller` over the ordinary `ninaku_runtime` pool | `organization.organizations`, `organization.business_units`, `platform.business_unit_verticals`, `platform.subscriptions`, `identity.memberships`, `identity.role_assignments`, `identity.access_scopes`, `platform.organization_provisioning_requests` | None — 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `GET /organizations` | Read model | — | Identity (owns `memberships`) reads across into `organization.organizations`; exposed as an Organization-facing read contract per D18's "read contract for compositions" mode | `identity.memberships`, `organization.organizations` | None — `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 with | Module | DB objects | Missing 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 routes | Command (mutates session GUCs for the transaction) | Shares its check with O4's organization-availability read | Identity implements the port; `TenantContextRunnerService` (foundation `tenancy`) applies the context | `identity.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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `GET /session/context` (existing route, extended) | Read model | Shares its guard with O3 | Identity (owns the route and the identity half); reads Organization's `organization.organizations` and `identity.memberships` for the org half | `identity.memberships`, `organization.organizations` | Same 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `PATCH /organization` | Command | — | Organization | `organization.organizations` | None: `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 with | Module | DB objects | Missing 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 built | Command | — | Platform (control plane) command over a table the Organization domain owns; guarded by Identity's D21 trigger | `platform.suspend_organization_as_staff`, `platform.reactivate_organization_as_staff`, `platform.archive_organization_as_staff`, `organization.organizations`, `organizations_archived_terminal_trg`, transitively `identity.assert_administration_preserved` | None: 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 |

***

## 3. Legal entity

### O7 — Register legal entity

**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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /legal-entities` | Command | — | Organization | `organization.legal_entities` | None: `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`) |

### O8 — Update legal entity profile

**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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `PATCH /legal-entities/{id}` | Command | — | Organization | `organization.legal_entities` | None: 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /business-units` | Command | — | Organization | `organization.business_units` | None: `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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `PATCH /business-units/{id}` | Command | Status transitions merged into O22 | Organization | `organization.business_units` | None: 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:**

| Path | Answer |
|---|---|
| No session / foreign tenant | `401 · identity.session_required` / `403 · tenant.context_rejected` |
| Member without `organization.account.manage` organization-wide | `403 · organization.action_forbidden`, audited as denied |
| Unknown business unit or another tenant's | `404 · organization.structure_not_found` (RLS makes them the same) |
| Unknown vertical code, or one `deprecated` or `retired` | `422 · 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 unit | `422 · organization.vertical_unavailable` |
| Malformed code (not `^[a-z][a-z0-9_]{1,79}$`) or business unit id | `400 · foundation.validation_failed` |
| Deactivating the organization's last active business-unit vertical | `409 · 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 raced | `200`, 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 code | `200` with `status = 'inactive'`; nothing is written or audited (as built) |
| Inactive or archived business unit on deactivation | `422 · organization.vertical_unavailable`, like activation (as built) |

**Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /business-units/{businessUnitId}/verticals/{verticalCode}/activation`, `.../deactivation` | Command | — | Organization: it owns the business unit and the route, and it already writes the first activation through provisioning; the table lives in the `platform` schema | `platform.business_unit_verticals`, `platform.verticals`, `organization.business_units`, `audit.records` | None 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /brands` | Command | — | Organization | `organization.brands` | None: `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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `PATCH /brands/{id}` | Command | Status transitions merged into O22 | Organization | `organization.brands` | None: 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /sites` | Command | — | Organization | `organization.sites` | None: `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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `PATCH /sites/{id}` | Command | Status transitions merged into O22 | Organization | `organization.sites` | None: 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /outlets` | Command | — | Organization | `organization.outlets` | None: `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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `PATCH /outlets/{id}` | Command | Status transitions merged into O22 | Organization | `organization.outlets` | None: 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /outlets/{id}/re-parenting` (task-oriented, not a raw field PATCH, because it carries cycle/ancestor semantics) | Command | — | Organization | `organization.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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /sales-channels` | Command | — | Organization | `organization.sales_channels` | No grant to `ninaku_runtime` today |

***

## 9. Effective assignments

### O20 — Assign outlet to legal entity, effective from a date

**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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /outlets/{id}/legal-entity-assignments` | Command | — | Organization | `organization.outlet_legal_entity_assignments` | None: `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:

| Status | When |
|---|---|
| `201` `{ id, outletId, legalEntityId, validFrom, validUntil, status }` | a row was created |
| `200` same body | the 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_failed` | malformed body or date, unknown field, `validUntil <= validFrom` when both are sent |
| `401`, `403 tenant.context_rejected` | no session, or the organization header is missing or not the caller's |
| `403 organization.action_forbidden` | the caller lacks the organization-wide permission (a member scoped to one business unit included); one `denied` record against the outlet |
| `404 organization.structure_not_found` | unknown 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_refused` | the 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /outlets/{id}/business-unit-assignments` | Command | Kept distinct from O20 — independent history tables, independent GiST constraints | Organization | `organization.outlet_business_unit_assignments` | None, 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):**

| Aggregate | States | Reversible? | Notes |
|---|---|---|---|
| `organization.organizations` | active / suspended / archived | suspended → active, gated by D21 | archived treated as terminal in this catalog |
| `organization.business_units` | active / inactive / archived | inactive → active | `validate_child_activation` blocks activating a child whose parent is not active |
| `organization.legal_entities` | active / inactive / archived | inactive → active | — |
| `organization.brands` | active / inactive / archived | inactive → active | — |
| `organization.sites` | active / inactive / archived | inactive → active | — |
| `organization.outlets` | active / inactive / archived | inactive → active | additionally gated by `outlets_hierarchy_trg`'s active-ancestor rule (O18) |
| `organization.sales_channels` | active / inactive / archived | inactive → active | — |
| `identity.memberships` | active / suspended / ended | suspended → 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_assignments` | no status column at all | **not applicable — revocation is a hard `DELETE`, not a soft transition** | the one aggregate in this catalog that breaks "never delete"; see O28 |
| `identity.invitations` | pending / accepted / revoked | none — both terminal states are final | a 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /{aggregate}/{id}/deactivation`, `.../reactivation` (one task-oriented pair per aggregate, not raw `PATCH status`) | Command | Absorbs the reviewer's O18 | Organization (structural aggregates); Identity (`memberships`) | see table above | see 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.

| Status | When |
|---|---|
| `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 body | the element already has the status asked for: no write, no record |
| `400 foundation.validation_failed` | the identifier is not a UUID |
| `401`, `403 tenant.context_rejected` | no session, or the organization header is missing or not the caller's |
| `403 organization.action_forbidden` | the 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_found` | unknown 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_required` | deactivating the business unit that holds the last active vertical of the organization |
| `422 organization.structure_refused` | reactivating 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `GET /organization/structure` | Read model | — | Organization | all `organization.*` tables above, plus the two resolver functions | No 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `GET /outlets/{id}/legal-entity-assignments/history`, `.../business-unit-assignments/history` | Read model | — | Organization | `organization.outlet_legal_entity_assignments`, `organization.outlet_business_unit_assignments` | No 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.truncated`
`true`, 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](#j1-decisions) and in
`DECISIONS.md` D27 to D30 (D30: the system roles by vertical that an
invitation offers).

**Actors.**

| Actor | Who | What they hold |
|---|---|---|
| Owner | The 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 identity | A future `manager`, `waiter`, `kitchen`, `bar`, `cashier`, `sales_associate` or `stock_keeper` who already has a Ninaku identity | A verified, non-revoked `identity.login_identifiers` row for the invited email or phone, or none yet for that identifier |
| Invitee without an identity | The same people, never having used Ninaku | Only the invitation message |
| Worker | The `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.**

| Step | Cases | Route |
|---|---|---|
| 1 (role list) | O30 (offerable roles read) | `GET /offerable-roles?scopeKind=...&scopeId=...&includeDescendants=...` |
| 1, 2 | O30 | `POST /invitations` |
| 3 | O32 (preview) | `POST /invitation-previews` |
| 4, 5 | none new: sign-in, sign-up, add-sign-in-method (Identity) | existing routes |
| 6 | O32 | `POST /invitation-acceptances` |
| 7 | O25 | `GET /memberships`, `GET /invitations` |
| 8 | O31, O33, O28, O29 | `POST /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):

11. **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.
12. **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.
13. **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.
14. **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).

| # | Decision | Default | Rejected alternative and why |
|---|---|---|---|
| a | Delivery | A 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_type` | Build 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) |
| b | The secret | 32 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 verified | Return 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 |
| c | Abuse by the inviter | Pending 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 |
| d | Seats | An 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 `seatLimit` | Reserve a seat per open invitation: idle invitations would block real hires and the seat trigger would need a second source of consumption |
| e | Privilege | J1 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_invitation` | Let 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 |
| f | Already a member | Invite 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 revealed | Silently send anyway: the acceptance would fail on `memberships_identity_organization_current_uq` and the owner would never learn why |
| g | Expiry | 7 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 reissue | Extend the old row: `expires_at` and `token_hash` are immutable by trigger, and an extended old token keeps a possibly leaked link alive |
| h | Roles offered | Seven 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 covers | One 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) |
| i | Delivery seam | The 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 them | Call 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:**

| Path | Answer |
|---|---|
| No session | `401 · identity.session_required` |
| `X-Organization-Id` of an organization the caller has no active membership in | `403 · 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 cursor | `400 · collection.cursor_tampered` / `collection.cursor_expired` / `collection.cursor_incompatible` |
| Another tenant's rows | Never returned (RLS), even with a hand-made cursor |
| A role assignment with no access scope | Cannot exist (`role_assignments_scoped_trg` is deferred and requires one); not a branch |
| An invitation's `token_hash` | Never 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `GET /memberships`, `GET /invitations` | Read model | The invitation list is folded in here (J1 step 7) | Identity | `identity.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_messages` | None 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](#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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /memberships/{id}/role-assignments` | Command | — | Identity | `identity.role_assignments`, `identity.access_scopes` | No 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `PATCH /role-assignments/{id}/scope` | Command | — | Identity | `identity.access_scopes`, transitively `identity.assert_administration_preserved` | No 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `DELETE /role-assignments/{id}` | Command | — | Identity | `identity.role_assignments`, `identity.access_scopes` | No 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /memberships/{id}/suspension`, `.../termination` | Command | — | Identity | `identity.memberships` | No 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 · code | Meaning |
|---|---|
| `400 · foundation.validation_failed` | Body 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_required` | No live session |
| `403 · tenant.context_rejected` | `X-Organization-Id` names an organization the caller has no active membership in |
| `403 · identity.action_forbidden` *new* | The caller lacks `organization.account.manage` organization-wide; mirrors `organization.action_forbidden` |
| `403 · identity.invitation_privilege_exceeded` *new* | The offered role grants `organization.account.manage` (J1 decision e) |
| `404 · identity.invitation_not_found` *new* | No invitation with this id in this tenant (O31, O33), or no invitation with this token (O32) |
| `409 · identity.invitation_closed` *new* | The invitation is accepted, revoked or expired (the three are not told apart), or its organization is not active |
| `409 · identity.invitation_already_open` *new* | An open, unexpired invitation for this identifier exists with a different role or scope configuration |
| `409 · identity.invitee_already_member` *new* | The 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` *new* | The organization already has as many open invitations as its plan's `identity.pending_invitations` |
| `409 · identity.seat_limit_reached` *new* | Accepting would exceed `identity.active_memberships` (`identity_membership_seat_limit`) |
| `403 · identity.invitation_recipient_mismatch` *new* | A valid token, but the caller's identity does not own the invited identifier verified |
| `422 · identity.invitation_refused` *new* | Role 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` *new* | A 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` *new* | Organization daily cap or per-identifier cooldown (J1 decision c); carries `Retry-After` |
| `429 · rate_limit.exceeded` | The 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:**

| Path | Answer |
|---|---|
| No session / foreign tenant | `401 · identity.session_required` / `403 · tenant.context_rejected` |
| A `cashier`, a `waiter`, or a `manager` (any scope: `manager` grants no `organization.account.manage`) invites | `403 · 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.manage` | `403 · identity.invitation_privilege_exceeded`, audited as denied |
| Role of another organization, inactive role, unknown role | `422 · 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 `restaurant` | `201`: neutral roles are always offerable; organization-wide covers every active business unit |
| Scope target of another organization or unknown | `422 · identity.invitation_refused` (backstop: the composite foreign keys `invitation_access_scopes_*_organization_fk`) |
| Two dimensions in one scope; `includeDescendants` on a non-outlet scope | `400 · foundation.validation_failed` (backstops: `invitation_access_scopes_single_dimension_chk`, `invitation_scopes_descendants_chk`) |
| The same role offered twice | `400 · foundation.validation_failed` (backstop: `invitation_role_assignments_invitation_role_uq`) |
| Identifier verified on a current member (active or suspended) of this organization | `409 · identity.invitee_already_member` |
| Identifier verified on someone who is not a member, or on nobody | `201`, the same body shape and timing class; nothing reveals which |
| Open, unexpired invitation for the identifier with another configuration | `409 · identity.invitation_already_open`; the owner revokes (O31) or reissues (O33) |
| Exact retry of a successful request | `200` with the same invitation, no second message |
| Two identical requests race | One 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_invitations` | `409 · 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 hour | `429 · identity.invitation_rate_limited` with `Retry-After` |
| The identifier already received 3 invitation messages in 24 hours from any organizations | `201`; 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 IP | `429 · rate_limit.exceeded` |
| Organization suspended or archived | `403 · 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /invitations`; `GET /offerable-roles` (read, below) | Command | Absorbs the expired-open auto-revoke | Identity | `identity.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.records` | Tables: 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:**

| Path | Answer |
|---|---|
| No session / foreign tenant / not an administrator | `401 · identity.session_required` / `403 · tenant.context_rejected` / `403 · identity.action_forbidden` (audited as denied) |
| Unknown id, or another tenant's invitation id | `404 · identity.invitation_not_found` (RLS makes them the same) |
| Already revoked | `200` with the same invitation; nothing written, no second audit record |
| Already accepted | `409 · identity.invitation_closed`; the owner uses O28 or O29 on the member instead |
| Open but expired | `200`, revoked (it frees the identifier for a fresh O30) |
| Revocation races an acceptance | Both lock the row; whichever commits first wins, the other gets `409 · identity.invitation_closed` |
| The message was already sent | Revocation 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /invitations/{invitationId}/revocation` | Command | — | Identity | `identity.invitations`, `identity.security_outbox_messages` (through the new function), `audit.records` | Tables: 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:**

0. **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.
1. `POST /invitation-acceptances` with `{ "token": "..." }` and a session.
   The token is in the body, never in a path or query string.
2. The use case hashes the token and calls the new `SECURITY DEFINER`
   `identity.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.
3. Answer `201` with `{ organizationId, organizationName, membershipId, roleCodes }`.
   The app selects that organization (O3) and opens it; it never shows
   provisioning.
   **Adversarial paths:**

| Path | Answer |
|---|---|
| No session | `401 · identity.session_required` (the preview still works) |
| Token unknown or guessed | `404 · identity.invitation_not_found`; identical body for every unknown token |
| Token that is not exactly 43 base64url characters, an unknown field, no token | `400 · foundation.validation_failed`; the shape of a token says nothing about any invitation |
| Token of an expired, revoked or already-accepted invitation | `409 · identity.invitation_closed` (the holder learns only that this link is dead) |
| Organization suspended or archived since the invitation | `409 · identity.invitation_closed` |
| Caller's identity does not own the identifier at all, or owns it unverified | `403 · 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 organization | `409 · identity.invitee_already_member` |
| Same identity retries after a success | `201` with the same `organizationId` and `membershipId`, nothing written again |
| Seat limit already used | `409 · 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 concurrently | The 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 invitees | `identity.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 sent | `422 · 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 sent | `422 · 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 IP | `429 · rate_limit.exceeded` |
| Invitee without an identity tries to accept before confirming sign-up | `401 · 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /invitation-acceptances`, `POST /invitation-previews` | Command, plus a read | The preview is folded in (J1 step 3) | Identity | `identity.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:**

| Path | Answer |
|---|---|
| No session / foreign tenant / not an administrator | `401` / `403 · tenant.context_rejected` / `403 · identity.action_forbidden` (audited as denied) |
| Unknown id or another tenant's | `404 · identity.invitation_not_found` |
| Accepted, or revoked by O31 | `409 · 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 identifier | `429 · identity.invitation_rate_limited` with `Retry-After`; nothing is revoked |
| An offered role became inactive or administrative since | `422 · identity.invitation_refused` / `403 · identity.invitation_privilege_exceeded`; nothing is revoked |
| An offered role's vertical is no longer active in the scope | `422 · identity.invitation_role_vertical_inactive`; nothing is revoked |
| Two reissues of the same invitation race | The 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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /invitations/{invitationId}/reissue` (revoke + insert as one command) | Command | Reuses O30's insert and enqueue path | Identity | `identity.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.records` | Tables: 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:

| Order | Decision | Meaning | Public answer (mapped by the consuming module) |
|---|---|---|---|
| 1 | `tenant_context_missing` | `identity.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` |
| 2 | `permission_missing` | `identity.effective_permission` refuses and the member never held a covering assignment | The plain refusal of the consuming module, identical to any other plain denial |
| 3 | `scope_unavailable` | `identity.permission_scope_unavailable`: the member holds the permission through a scope whose place or the outlet is inactive | `403 authorization.scope_unavailable`, "The place you are assigned to is not available. Contact your administrator." |
| 4 | `capability_not_entitled` | `platform.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." |
| 5 | `outlet_not_operational` | `organization.assert_outlet_operational` refuses the outlet, an ancestor, its site, brand or business unit | `422 authorization.outlet_not_operational` |
| 6 | `vertical_inactive` | The 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` |
| 7 | `allowed` | Every 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:

| Route | Answers |
|---|---|
| `/platform-admin/plan-versions/{versionId}/capabilities` | `201` 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}/removals` | `204`; `404`, `409` and `422` as above |
| `/platform-admin/plan-versions/{versionId}/limits` | `201` declared now, `200` changed or the same; `404`, `409`; `422` unknown or deprecated limit |
| `/platform-admin/organizations/{organizationId}/capability-overrides` | `201` 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}/revocation` | `204` (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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No tenant route; five staff routes administer the catalog | Read (a check, not a mutation) | — | Platform owns `OperationAuthorizationContract`; Identity keeps `PermissionCheckContract` for callers that only need the permission piece | `platform.authorize_operation()`, `platform.assert_operation_allowed()`, `platform.effective_capability()`, `identity.effective_permission()`, `identity.permission_scope_unavailable()`, five `*_as_staff` functions | None: `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 with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Deferred — no route proposed yet; internal port swap only | Read (policy lookup) | — | Organization owns the future override table; Identity keeps owning `VerificationPolicyPort` and every use case that calls it | None yet — table does not exist in the schema reviewed | N/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).

| Group | Routes | Cases served |
|---|---|---|
| Provisioning & context | `POST /organizations`, `GET /organizations`, `GET /session/context` | O1, O2, O4 (O3 is a guard, not a route) |
| Organization profile | `PATCH /organization`, `GET /organization`, `GET /organization/structure`, `GET /sites/{id}/outlets` | O5, O23 |
| Organization lifecycle (staff only) | `POST /platform-admin/organizations/{organizationId}/{suspension,reactivation,archival}` | O6 |
| Legal entity | `POST /legal-entities`, `PATCH /legal-entities/{id}` | O7, O8 |
| Business unit | `POST /business-units`, `PATCH /business-units/{id}`, `POST /business-units/{id}/verticals/{code}/{activation,deactivation}` | O9, O10, O11 |
| Brand | `POST /brands`, `PATCH /brands/{id}` | O12, O13 |
| Site | `POST /sites`, `PATCH /sites/{id}` | O14, O15 |
| Outlet | `POST /outlets`, `PATCH /outlets/{id}`, `POST /outlets/{id}/re-parenting` | O16, O17, O18 |
| Sales channel | `POST /sales-channels` | O19 |
| Assignments | `POST /outlets/{id}/legal-entity-assignments`, `.../business-unit-assignments`, `GET .../legal-entity-assignments`, `GET .../business-unit-assignments` | O20, O21, O24 |
| Lifecycle | `POST /{aggregate}/{id}/{deactivation,reactivation}` (one pair per structural aggregate) | O22 |
| Memberships & roles | `GET /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) |
| Invitations | `GET /invitations`, `POST /invitations`, `POST /invitations/{invitationId}/revocation`, `POST /invitations/{invitationId}/reissue`, `POST /invitation-previews`, `POST /invitation-acceptances` | O25 (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}/revocation` | O34 |
| Verification policy override | none proposed yet | O35 |
| 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`).

| Group | Cases | Why this boundary |
|---|---|---|
| **0 — grants** | none directly | Shipped: 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 foundation** | O1, O2, O3, O4 | Everything else needs a real tenant context and a real `/session/context`; today's implementation is a stub. |
| **2 — organization profile & lifecycle** | O5, O6 | Smallest write surface once context exists. |
| **3 — flat catalog entities** | O7, O8, O9, O10, O11, O12, O13, O14, O15, O19 | Same 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 & hierarchy** | O16, O17, O18 | The one structural aggregate with a real cycle-prevention invariant; worth its own slice for focused review. |
| **5 — effective assignments & structure reads** | O20, O21, O23, O24 | Depends 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 J1 | J1 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, O33 | J1 slices J1-1, J1-1b, J1-R, J1-2, J1-3 and J1-5 below. |
| **8 — authorization contract** | O34 | Needed once any consuming module (outside this catalog) starts calling it; can ship right after Group 6. |
| **9 — verification policy override** | O35 | Lowest 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.

| Slice | Delivers | Size | Proof (kind → what) | Adversarial cases it must include |
|---|---|---|---|---|
| **J1-V — launch verticals** | Migration: `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 | ~100 | DB 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 vertical | Re-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 schema** | As 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 each | DB contract → every check and function; concurrency → the per-identifier and per-organization advisory locks | Unchanged 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 | ~350 | DB contract → each fix; concurrency → revocation committed while the message is leased, then the lease expires and a second claim runs | Hostile 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 | ~350 | DB contract → seed, column, check, function per scope kind; attacker contract → as `ninaku_runtime` | A `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` | ~350 | Unit → use case order and idempotency; integration → repository against PostgreSQL (RLS, triggers); e2e → codes and audit records | Member 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` | ~250 | Integration → read model against PostgreSQL per scope kind; e2e → codes | Never 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 invitations** | `invitation` 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](#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` | ~300 | Unit → mapping and copy; worker integration → a claimed invitation message is sent by email and by WhatsApp and its ciphertext is wiped | A 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` | ~400 | Unit → use case order (permission, roles, vertical rule, member check, open-invitation branch, `schedule`, audit); integration → repository and adapter against PostgreSQL; e2e → HTTP codes | Every 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` | ~400 | DB 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 existing | Unknown 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` | ~300 | Integration → read models, RLS isolation, keyset cursor; e2e → codes and the seat numbers after J1-3 | Cashier 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` | ~350 | DB 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 reissue | Revoke 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) | ~400 | Integration → D21 paths; concurrency → two administrators removed at once (`identity.lock_administration_tenant`); e2e → codes, the seat freed by a suspension lets a waiting acceptance through | Last 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` | ~700 | DB contract → vertical backstop, D21 on a scope change, cross-tenant; concurrency → vertical deactivation and two administrators narrowing each other; integration → repository; e2e → codes, the journey | An 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.
