---
url: /reference/ORGANIZATION_CASE_MATRIX.md
---
# Organization Case Matrix

One row per behavioral case for every use case named in
[`ORGANIZATION_USE_CASES.md`](ORGANIZATION_USE_CASES.md). Each case names
the test that would prove it, quoted verbatim as a spec file path, then the
separator "›", then the exact test title, exactly like
[`IDENTITY_CASE_MATRIX.md`](IDENTITY_CASE_MATRIX.md). A case with no proving
test answers `—` and is listed under [Gaps](#gaps), together with the
lowest layer that could prove it honestly.

**O1 to O18 and O20 to O24 are proven (Group 0 at the database layer, Group 1, O5, the staff-only O6, the legal-entity routes O7 and O8, the business-unit routes O9 and O10, the vertical routes O11, the brand routes O12 and O13 the site routes O14 and O15 and the outlet routes O16, O17 and O18 over HTTP, the lifecycle routes of O22 and the structure reads of O23 and O24), and O1 is proven against the real vertical catalog seeded by migration `0050`.**
Every other row in this catalog still answers `—` and is a gap by
construction, not by omission. The one Group 1 row left open is
impersonation (O3), which belongs to a later case of its own.

***

## Totals

| | |
|---|---:|
| Use cases | 35 |
| Per-case scenario rows | 586 |
| Per-case scenario rows proven | 579 (the same as before, plus the four `verticalCodes` rows of O9 (D1), plus the impersonation row of O3, the deactivation row of O11, the impossible row of O25, the runtime system-role row and the two-organization delivery row of O30; and fifteen new proven rows: the lock order of O18 against the operational guard, five end-date rows each for O20 and O21, three rows of O23 on the site date and the boundary resolution, and the history cap of O24; and the provisioning row on country and currency letter case) |
| Cross-tenant and concurrency invariants | 11 (11 proven) |
| Gaps | 7 |

***

## Provisioning, discovery, tenant context

### O1 — Provision organization

DB-layer proof shipped in Group 0 (baseline revision 28), against real
PostgreSQL, in `database/tests/prepare/prepare-database-contracts.ts`. The
`POST /organizations` route is served by the Organization module and proven by
`organization-provisioning.repository.integration-spec.ts` (repository against
PostgreSQL) and `organizations-provisioning.e2e-spec.ts` (HTTP); the hardening
rows are proven by the `organizations-provisioning-concurrency`, `-input`,
`-content` and `-attacks` e2e specs. The audit record of the creation is proven
by `organization-provisioning-audit-contracts.ts` (database) and
`organizations-provisioning-audit.e2e-spec.ts` (HTTP).

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: signed-in identity provisions a new organization with a business unit, vertical(s), and a chosen plan offering, per country (CO/COP and EC/USD) | `201 Created`, caller holds an org-wide `organization.account.manage` role assignment | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `creates the organization with its membership, subscription and owner role assignment`; `test/e2e/organizations-provisioning.e2e-spec.ts` › `creates the organization and makes the caller its owner` |
| Provisioning with `hospitality`, `restaurant` or `retail` on a database built by `db:prepare`, with no test-inserted vertical (J1 finding 11) | `201 Created`, and the first business unit activates that vertical; migration `0050` and `0060_platform.sql` seed the three rows as `active` | Prepare contract `organizationProvisioningSeededVerticalsHold` (`NINAKU_PREPARE_ORGANIZATION_SEEDED_VERTICALS_OK`): the three rows exist with their English names and `status = 'active'`, `provision_organization_for_caller` succeeds for each with no vertical inserted by the contract, an unknown code and a `deprecated` or `retired` vertical are still refused with `platform_business_unit_vertical_active_context`; e2e `Provisioning with the launch verticals seeded by db:prepare (e2e) > creates an organization whose first business unit activates the seeded hospitality vertical` (and the same title with `restaurant` and `retail`) |
| No partial garbage: a blocked owner identity refuses before any insert | No organization, membership, subscription or provisioning-request row survives a failed provisioning attempt | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeCannotProvisionForABlockedIdentity`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `refuses %s identity as owner` |
| Idempotent retry with the same idempotency key and same payload | Same `organization_id` returned, no duplicate row | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `answers the same organization when the same key and payload are replayed`; `test/e2e/organizations-provisioning.e2e-spec.ts` › `answers the same organization when the same key and body are replayed` |
| Idempotent retry with the same idempotency key but a different payload | Refused — fingerprint mismatch | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `refuses the same key with a different payload as a reused key`; `test/e2e/organizations-provisioning.e2e-spec.ts` › `refuses the same key with a different body` |
| Idempotent retry of a COMPLETED request after staff paused the offering, closed the market or made the offering private | Same `organization_id`, no new row: the stored result is answered before the Market, availability and visibility checks (migration `0052`) | `database/tests/prepare/organization-provisioning-replay-contracts.ts` › `organizationProvisioningReplayHolds`; `test/e2e/organizations-provisioning-replay.e2e-spec.ts › answers the same organization when the offering was paused after the first answer`; `test/e2e/organizations-provisioning-replay.e2e-spec.ts › answers the same organization when the offering was made private after the first answer`; `test/e2e/organizations-provisioning-replay.e2e-spec.ts › answers the same organization when the market was closed after the first answer` |
| A NEW key on an offering that was paused or made private, or on a closed market | Refused exactly as before (`422 · organization.provisioning_refused`), nothing created | `database/tests/prepare/organization-provisioning-replay-contracts.ts` › `organizationProvisioningReplayHolds`; `test/e2e/organizations-provisioning-replay.e2e-spec.ts › still refuses a NEW key on the paused offering and creates nothing`; `test/e2e/organizations-provisioning-replay.e2e-spec.ts › still refuses a NEW key on an offering made private after the first answer` |
| The same key with a different payload, or presented by another account, after the offering was paused | `409 · idempotency.key_reused` (the replay mismatch), never the stored organization | `database/tests/prepare/organization-provisioning-replay-contracts.ts` › `organizationProvisioningReplayHolds`; `test/e2e/organizations-provisioning-replay.e2e-spec.ts › still answers the key mismatch for the same key with a different body after the pause`; `test/e2e/organizations-provisioning-replay.e2e-spec.ts › does not let another account replay the completed key after the pause` |
| Caller cannot name another Identity or Role as owner | The function has no such parameter (structural) and the caller is the sole member of the organization it provisions (behavioral) | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeCannotNameAnotherOwner`; `test/e2e/organizations-provisioning.e2e-spec.ts` › `rejects a body with %s` |
| Chosen plan offering belongs to another country's Market | Refused, never silently substituted | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesAnOfferingFromAnotherCountry`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `refuses an offering of another country market`; `test/e2e/organizations-provisioning.e2e-spec.ts` › `refuses %s and creates nothing` |
| Chosen plan offering is not `active` | Refused before any insert | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesAnInactiveOffering`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `refuses an offering that is not active`; `test/e2e/organizations-provisioning.e2e-spec.ts` › `refuses %s and creates nothing` |
| Unknown country/currency/locale, or a country with no active Market | Refused before any insert | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesAnUnknownCountry`/`...Currency`/`...Locale`/`organizationRuntimeRefusesACountryWithNoMarket`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `refuses an unknown country`; `...currency`; `...locale`; `refuses a country without an active market`; `test/e2e/organizations-provisioning.e2e-spec.ts` › `refuses %s and creates nothing` |
| Owner identity is blocked, disabled, or a service identity | Refused | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeCannotProvisionForABlockedIdentity`; `src/modules/organization/infrastructure/provisioning/organization-provisioning.repository.integration-spec.ts` › `refuses %s identity as owner` (403 `organization.provisioning_forbidden`) |
| API caller (`ninaku_runtime`) attempts `platform.provision_organization` directly | Refused — `permission denied for function provision_organization`; must keep passing unchanged | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeCannotProvisionOrganizations` |
| Concurrency: the same key and body raced by several requests at once | Every request answers `201` with the same `organization_id`; exactly one organization, membership, subscription and provisioning-request row exists for that key | `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › answers the same organization to every concurrent replay of one key and creates exactly one` |
| Concurrency: one key raced with different bodies; another identity presenting someone else's key and body | One body wins (`201`), the rest are refused `409 · idempotency.key_reused`, never two organizations; a different identity gets `409 · idempotency.key_reused` with no trace of the first organization | `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › lets one body win a key raced with different bodies and refuses the others as key reuse`; `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › never hands one identity the organization another identity provisioned with the same key and body` |
| Concurrency: several different keys race for one organization code, in any letter case | Exactly one `201`, the rest `409 · organization.code_taken`; one organization row and one provisioning-request row | `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › lets exactly one of several different keys claim an organization code, whatever its letter case` |
| Concurrency: free trial (`trial_days > 0`) claimed twice at once by one identity, and once each by two identities | One `201` and one `422 · organization.provisioning_refused` with the trial detail for the same identity (the loser used to answer `500` from `identity_trial_grants_pkey`; fixed); two identities each get their trial | `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › grants one free trial to an identity that provisions two trial organizations at once`; `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › gives each of two different identities its own free trial when they provision at once` |
| Input boundaries: code, name, business unit code and name, vertical list | Code 1 to 50 characters after trim (51, blank, tab and newline, a number and `null` are `400`); names up to 200 after trim; business unit code 1 to 80 of `[A-Za-z0-9._-]` starting alphanumeric; at most 20 verticals (21 and 100 are `400`); surrounding whitespace is stored trimmed, and a trimmed twin of an existing code is `organization.code_taken`; a refusal reserves nothing | `test/e2e/organizations-provisioning-input.e2e-spec.ts › accepts an organization code of exactly 50 characters`; `test/e2e/organizations-provisioning-input.e2e-spec.ts › rejects an organization code of %s with 400 and reserves nothing`; `test/e2e/organizations-provisioning-input.e2e-spec.ts › stores an organization code and name trimmed of surrounding whitespace`; `test/e2e/organizations-provisioning-input.e2e-spec.ts › holds a code and its padded upper-case twin to the same case-insensitive uniqueness`; `test/e2e/organizations-provisioning-input.e2e-spec.ts › accepts a %s`; `test/e2e/organizations-provisioning-input.e2e-spec.ts › rejects %s with 400 and creates nothing`; `test/e2e/organizations-provisioning-input.e2e-spec.ts › rejects 21 verticals and 100 verticals with 400, but accepts exactly 20 distinct ones` |
| Hostile input: a NUL character in any text field, and a body that is not an object | `400` for NUL in code, name, business unit name, locale, country or currency (it used to reach PostgreSQL and answer `500`; fixed); `400` for an array, `null`, a string or an empty object | `test/e2e/organizations-provisioning-input.e2e-spec.ts › answers 400, never a 5xx, for a NUL character in %s`; `test/e2e/organizations-provisioning-input.e2e-spec.ts › rejects a body that is %s with 400`; `src/modules/organization/presentation/dtos/provision-organization-request.schema.spec.ts › rejects %s` |
| Content the database judges: repeated or unknown vertical, upper-case locale, an offering id that is really a plan id or a market id | `422 · organization.provisioning_refused` with the matching detail, nothing created, no SQL or constraint name in the body | `test/e2e/organizations-provisioning-content.e2e-spec.ts › refuses %s with 422 and the matching detail` |
| Country and currency in any letter case, digits or non-ASCII letters | The shared `CountryCodeSchema` and `CurrencyCodeSchema` (`src/foundation/validation/schemas/iso-code.schema.ts`) trim the value, accept only two (country) or three (currency) ASCII letters and upper-case them, so `co` provisions exactly like `CO` and `usd` like `USD`; digits, non-ASCII letters and a wrong length are `400`; a well formed unknown code is still `422` from the reference data | `test/e2e/organizations-provisioning-content.e2e-spec.ts › provisions a country and a currency in lower case exactly like their canonical upper case`; `test/e2e/organizations-provisioning.e2e-spec.ts › rejects a body with %s`; `test/unit/country-and-currency-request-fields.spec.ts › reads %j exactly like the canonical EC` |
| Content stored verbatim; currency other than the offering market currency | Unicode, emoji, right-to-left marks, combining characters, SQL metacharacters and template payloads are stored and listed back unchanged; the letter case of the code is kept; a currency different from the offering market currency is accepted as the organization default while the subscription keeps billing in the offering currency (deliberate: the default currency is an initial preference, see O1; the same key with another currency is a different request, `409 · idempotency.key_reused`, one organization stored) | `test/e2e/organizations-provisioning-content.e2e-spec.ts › stores %s verbatim and lists it back unchanged`; `test/e2e/organizations-provisioning-content.e2e-spec.ts › keeps the letter case of a stored organization code`; `test/e2e/organizations-provisioning-content.e2e-spec.ts › stores a currency that differs from the offering market currency and keeps billing in the offering currency`; `test/e2e/organizations-provisioning-content.e2e-spec.ts › treats the same key with another currency as a different request and stores one organization` |
| Attacker: fields the caller must never choose (`owner`, `ownerIdentityId`, `identityId`, `membershipId`, `roleId`, `organizationId`, `status`) | `400`, nothing reserved, no organization created | `test/e2e/organizations-provisioning-attacks.e2e-spec.ts › refuses a body that smuggles a %s field with 400 and reserves nothing` |
| Attacker: learning who holds a taken code | `409 · organization.code_taken` carries no organization name, id, identity or SQL text of the holder | `test/e2e/organizations-provisioning-attacks.e2e-spec.ts › never tells the caller who holds a code, the name or the id of the organization that took it` |
| Attacker: malformed `Idempotency-Key` (plain word, version 1 uuid, uuid with trailing text, 161 characters); upper-case replay | `400` for each malformed key; an upper-case replay of a key is the same key and answers the same organization | `test/e2e/organizations-provisioning-attacks.e2e-spec.ts › refuses an Idempotency-Key that is %s with 400`; `test/e2e/organizations-provisioning-attacks.e2e-spec.ts › treats an upper-case replay of a key as the same key and answers the same organization` |
| Failures with no public mapping: the seeded global owner role is inactive; the plan holds no seat for the owner | Generic `500` problem with `foundation.internal_error`, no role, seat, table or constraint text, zero rows left. Recommendation, not shipped behaviour: both deserve a public `422` (open product decision) | `test/e2e/organizations-provisioning-attacks.e2e-spec.ts › answers the generic internal problem and leaves no rows when the seeded global owner role is inactive`; `test/e2e/organizations-provisioning-attacks.e2e-spec.ts › answers the generic internal problem and leaves no rows when the plan holds no seat for the owner` |
| Identity blocked between sign-in and the call | `401 · identity.session_required`, nothing provisioned | `test/e2e/organizations-provisioning-attacks.e2e-spec.ts › refuses a token whose identity was blocked after sign-in and provisions nothing` |
| The creation leaves one audit record: caller as actor, new organization as target, created values as after-only changes | One sealed tenant record `organization.provisioned` (`success`, authorization `allowed`) with the caller as actor, the owner membership, the new organization as organization and as `organization.organization` target, and the changes `organization.code`, `organization.name`, `organization.default_locale`, `organization.default_currency` and `platform.plan_offering_id`, written by `platform.provision_organization` in the transaction of the creation; the vocabulary row is tenant scope, `administration`, `medium`, actor and target required | `database/tests/prepare/organization-provisioning-audit-contracts.ts` › `theVocabularyKnowsTheProvisionedAction`; `database/tests/prepare/organization-provisioning-audit-contracts.ts` › `provisioningLeavesOneRecordOfTheCreation`; `test/e2e/organizations-provisioning-audit.e2e-spec.ts` › `records the creation with the caller as actor, the new organization as target and the created values` |
| A replay of the same key and body | No second record: the replay returns before anything is written | `database/tests/prepare/organization-provisioning-audit-contracts.ts` › `aReplayOfTheSameRequestLeavesStillOneRecord`; `test/e2e/organizations-provisioning-audit.e2e-spec.ts` › `writes no second record when the same key and body are replayed` |
| A refused provisioning, or one that rolls back | No record: unknown country, offering of another country, code already taken and a second free trial leave the record count unchanged, a rolled back provisioning takes its record with it, and over HTTP an unknown country or locale, an offering of another country, a key reused with another body and a code already taken write none | `database/tests/prepare/organization-provisioning-audit-contracts.ts` › `refusedProvisioningLeavesNoRecord`; `database/tests/prepare/organization-provisioning-audit-contracts.ts` › `aRolledBackProvisioningTakesItsRecordWithIt`; `test/e2e/organizations-provisioning-audit.e2e-spec.ts` › `writes no record when it refuses %s`; `test/e2e/organizations-provisioning-audit.e2e-spec.ts` › `writes no record for a key reused with another body, nor for a code already taken` |
| Who can read the record | Only the new organization's tenant context sees it; another tenant reads none | `database/tests/prepare/organization-provisioning-audit-contracts.ts` › `theRecordIsReadableOnlyInsideTheNewOrganization` |

### O2 — Discover my organizations

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: identity with two active memberships lists both organizations | `200 OK`, two rows | `test/e2e/my-organizations.e2e-spec.ts` › `lists the organizations the caller can enter, newest membership first, with a pageInfo`; `src/modules/identity/infrastructure/my-organizations/my-organizations.repository.integration-spec.ts` › `lists the organization with its id, code, name and membership status`; › `never returns the organizations of another identity` |
| Identity with zero memberships | `200 OK`, empty page | `test/e2e/my-organizations.e2e-spec.ts` › `answers an empty page, not an error, for a caller without memberships`; `src/modules/identity/infrastructure/my-organizations/my-organizations.repository.integration-spec.ts` › `answers an empty page for an identity without memberships` |
| Identity with a suspended or ended membership | That organization is excluded, never presented | `src/modules/identity/infrastructure/my-organizations/my-organizations.repository.integration-spec.ts` › `leaves out a %s membership in a %s organization`; › `leaves out a ended membership in a active organization`; `test/e2e/my-organizations.e2e-spec.ts` › `leaves out a %s membership in a %s organization`; › `leaves out a ended membership in a active organization` |
| Identity with a membership in a suspended/archived organization | Never presented as selectable | `src/modules/identity/infrastructure/my-organizations/my-organizations.repository.integration-spec.ts` › `leaves out a %s membership in a %s organization`; `test/e2e/my-organizations.e2e-spec.ts` › `leaves out a %s membership in a %s organization` |
| Pagination | Keyset cursor, no `OFFSET`/`LIMIT` | `src/modules/identity/infrastructure/my-organizations/my-organizations.repository.integration-spec.ts` › `pages forward and backward across more than one page`; › `breaks a tie on the join instant by membership id`; `test/e2e/my-organizations.e2e-spec.ts` › `walks the rest of the organizations with the cursor, without duplicates`; › `refuses a cursor minted for another identity as collection.cursor_incompatible` |
| Adversarial: `limit` of 0, negative, empty, above 100, fractional, non-numeric or repeated; limit of 100; no limit | `400 · foundation.validation_failed` on the `limit` field for every refused value; `limit=100` accepted; 50 items and a next page when absent | `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a $what limit as foundation.validation_failed on the limit field`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a repeated limit parameter instead of picking one`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a query parameter the route does not declare`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › accepts the maximum limit of 100`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › honours a limit of 1 by returning one item and announcing the next page`; `test/e2e/my-organizations-paging.e2e-spec.ts › answers 50 items and announces a next page when no limit is given` |
| Adversarial: forged, altered, expired, foreign-route, empty, oversized or doubled cursors | `400`: `collection.cursor_tampered` (signature or payload altered), `collection.cursor_invalid` (key id, version or shape), `collection.cursor_expired`, `collection.cursor_incompatible` (another route), `foundation.validation_failed` (empty, oversized, `after` with `before`); never a page, never `500` | `test/e2e/my-organizations-query-hardening.e2e-spec.ts › accepts an untampered cursor, proving the tamper cases below fail for the flip alone`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a cursor whose signature was altered as collection.cursor_tampered`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a cursor whose payload was altered as collection.cursor_tampered`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a tampered cursor sent as before the same way`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a cursor with an altered $what as collection.cursor_invalid`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a token that is not shaped like a cursor as collection.cursor_invalid`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses an empty cursor as a validation failure, never as a first page`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses an oversized cursor as a validation failure before decoding it`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses after and before together, even when both are perfectly valid`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses a cursor of another route that binds nothing but the identity as collection.cursor_incompatible`; `test/e2e/my-organizations-query-hardening.e2e-spec.ts › refuses an expired cursor as collection.cursor_expired` |
| Memberships change while the caller pages, and a caller with 120 memberships walks them all | Forward walk skips a membership suspended or ended after page one, shows a later-arriving older membership once, never shows a newer one already passed, and covers 120 memberships exactly once in order with limit 50 or 100 | `test/e2e/my-organizations-paging.e2e-spec.ts › skips a not-yet-returned membership suspended after page one and loses no selectable one`; `test/e2e/my-organizations-paging.e2e-spec.ts › keeps the position after page one even when the last returned membership is suspended`; `test/e2e/my-organizations-paging.e2e-spec.ts › shows a membership that joined earlier than the cursor position on a later page, exactly once`; `test/e2e/my-organizations-paging.e2e-spec.ts › does not show, in a forward walk already past it, a membership that joined more recently than the cursor position`; `test/e2e/my-organizations-paging.e2e-spec.ts › walks all 120 memberships exactly once and in order with a limit of 50`; `test/e2e/my-organizations-paging.e2e-spec.ts › walks all 120 memberships exactly once and in order with a limit of 100`; `test/e2e/my-organizations-paging.e2e-spec.ts › answers a last page of exactly the limit without inventing an empty next page` |
| Organization names with unicode, quotes, markup, SQL metacharacters or 200 characters | Returned verbatim, in join order, query intact | `test/e2e/my-organizations-paging.e2e-spec.ts › returns every name verbatim and in join order without breaking the query` |
| Identity blocked, disabled or archived after sign-in, with a token issued earlier | `401 · identity.session_required` on both reads, no organization id in the body; reactivation does not revive the old token | `test/e2e/identity-reads-after-block.e2e-spec.ts › answers 401 identity.session_required on both reads with a token issued before the identity became %s`; `test/e2e/identity-reads-after-block.e2e-spec.ts › keeps refusing the old token after the identity is reactivated, because blocking revokes its sessions for good` |

### O3 — Select organization / establish tenant context

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: caller with an active membership sends `X-Organization-Id` for that organization | Transaction proceeds with RLS scoped to that organization | `src/modules/identity/application/tenant-context/resolve-tenant-context.use-case.spec.ts` › `builds the tenant context from the caller and their active membership`; `src/modules/identity/infrastructure/tenant-context/tenant-membership.repository.integration-spec.ts` › `finds the active membership of an identity in an active organization` (resolution only; the HTTP guard lands with the first tenant-scoped route) |
| Adversarial: known UUID of a foreign organization, no membership there | `403 · tenant.context_rejected` | `src/modules/identity/application/tenant-context/resolve-tenant-context.use-case.spec.ts` › `rejects the tenant context when the caller has no active membership there`; `src/modules/identity/infrastructure/tenant-context/tenant-membership.repository.integration-spec.ts` › `never reveals the membership of another identity`; › `finds nothing for an organization the identity does not belong to` (resolution only) |
| Membership exists but is suspended or ended | `403 · tenant.context_rejected` | `src/modules/identity/infrastructure/tenant-context/tenant-membership.repository.integration-spec.ts` › `finds nothing for a %s membership in a %s organization`; › `finds nothing for a ended membership in a active organization` (resolution only) |
| Organization exists but is not `active` | `403 · tenant.context_rejected` | `src/modules/identity/infrastructure/tenant-context/tenant-membership.repository.integration-spec.ts` › `finds nothing for a %s membership in a %s organization` (resolution only) |
| Header omitted on a route that requires tenant context | `403 · tenant.context_rejected` | `src/modules/identity/application/tenant-context/resolve-tenant-context.use-case.spec.ts` › `rejects %j without touching the database` (resolution only) |
| Impersonation session exists but is expired, ended, or names a different actor/organization/membership triple | HTTP answers `403 · tenant.context_rejected` through `assert_tenant_context`; no HTTP header carries impersonation, so the row is proven at the database layer | `database/tests/contracts/identity-adversarial.ts` › `SP01 future support approval is not active`; › `SP02 expired support approval denied`; › `SP05 ended support approval denied`; › `SP06 a support approval that names another organization is denied`; › `SP07 a support approval whose actor is another identity is denied`; › `SP08 a support approval that names another membership is denied`; › `SP09 a support approval that names the actor, the organization and the membership of the context is active` (the positive control that shows the denials come from the approval and not from the setup); `database/tests/contracts/tenant-context-establish-contracts.ts` › `J1-R2 an impersonation session that does not exist is refused with 42501` |
| Candidate is uppercase, of a non-string type, or the membership lookup fails | Uppercase resolves to the canonical lowercase id; number, array, object and `null` are refused without a database call; a database failure surfaces as itself, never as `tenant.context_rejected` | `src/modules/identity/application/tenant-context/resolve-tenant-context.use-case.spec.ts › looks up and returns the canonical lowercase organization id when the candidate is uppercase`; `src/modules/identity/application/tenant-context/resolve-tenant-context.use-case.spec.ts › rejects $what given by an untyped caller without touching the database`; `src/modules/identity/application/tenant-context/resolve-tenant-context.use-case.spec.ts › lets a database failure surface as itself, neither hidden nor disguised as a rejected tenant context` |

### O4 — Read current organization context

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: caller with an active membership sends a valid `X-Organization-Id` | `200 OK` with the resolved organization, role(s), and permission summary | `test/e2e/session-context.e2e-spec.ts` › `selects the organization named by X-Organization-Id when the caller is an active member`; › `lists the roles and permissions the caller holds in the selected organization` |
| A role is inactive, or a permission is deprecated | Only active roles and the active permissions they grant are reported, sorted by code | `src/modules/identity/infrastructure/session-context/session-context.repository.integration-spec.ts` › `lists only active roles and only the active permissions they grant, sorted`; › `is empty for a member with no role assignment` |
| Adversarial: the caller belongs to two organizations, or names one that is not theirs | Roles and permissions are those of the selected organization only, and empty for an unselected or foreign one | `test/e2e/session-context.e2e-spec.ts` › `keeps the roles of one organization out of another one the caller also belongs to`; › `never shows the roles of an organization the caller was not selected into, even when named` |
| No header, at least one active membership exists | `200 OK`, `selectedOrganizationId: null`, `hasAvailableOrganizations: true` | `test/e2e/session-context.e2e-spec.ts` › `offers organizations to select once the identity has an active membership, without selecting one`; `src/modules/identity/infrastructure/tenant-context/tenant-membership.repository.integration-spec.ts` › `is true when the identity has an active membership in an active organization` |
| Header names a foreign organization | `200 OK`, `selectedOrganizationId: null` — never confirms the foreign organization exists | `test/e2e/session-context.e2e-spec.ts` › `answers as if the header were absent for an organization the caller does not belong to, never confirming it exists`; › `ignores a malformed X-Organization-Id (%j) instead of refusing the request` |
| All memberships suspended or ended | `hasAvailableOrganizations: false` | `test/e2e/session-context.e2e-spec.ts` › `offers nothing and selects nothing for a %s membership in a %s organization`; `src/modules/identity/infrastructure/tenant-context/tenant-membership.repository.integration-spec.ts` › `is false when the only membership is %s in a %s organization` |
| Adversarial: `X-Organization-Id` in uppercase, sent twice, of 10 thousand characters, or carrying SQL metacharacters | Uppercase selects the organization and answers its lowercase id; the others select nothing and answer `200`, never `500`; a colleague never inherits another member's roles | `test/e2e/session-context-header.e2e-spec.ts › selects the organization when the header carries the id in uppercase and answers the canonical lowercase id`; `test/e2e/session-context-header.e2e-spec.ts › treats two X-Organization-Id headers as absent instead of picking one`; `test/e2e/session-context-header.e2e-spec.ts › treats an id followed by anything else in the header as absent`; `test/e2e/session-context-header.e2e-spec.ts › answers 200 and selects nothing for a header of 10 thousand characters`; `test/e2e/session-context-header.e2e-spec.ts › selects nothing for $what`; `test/e2e/session-context-header.e2e-spec.ts › never lends the roles of a member to another member of the same organization` |
| The membership ends or is suspended, or the organization is suspended, while roles are still assigned | `selectedOrganizationId: null`, no roles and no permissions | `test/e2e/session-context-header.e2e-spec.ts › reports no roles and no selection once the membership ended while it still held a role`; `test/e2e/session-context-header.e2e-spec.ts › reports no roles and no selection once the membership is suspended while it still holds a role`; `test/e2e/session-context-header.e2e-spec.ts › reports no roles and no selection once the organization is suspended while the member still holds a role` |
| Two roles share a permission; a role is retired or a permission deprecated after being linked | Permission listed once; roles and permissions sorted by code; retired roles and deprecated permissions disappear at once | `test/e2e/session-context-access.e2e-spec.ts › lists a permission granted by two roles once, with roles and permissions sorted by code`; `test/e2e/session-context-access.e2e-spec.ts › stops reporting a role and its permissions the moment the role is retired`; `test/e2e/session-context-access.e2e-spec.ts › keeps an active role but drops a permission once that permission is deprecated` |

## Organization profile and lifecycle

### O5 — Update organization profile

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: caller with org-wide `organization.account.manage` updates name/locale/currency | `200 OK` with the profile `{ organizationId, code, name, localeCode, currencyCode }`; only the changed columns are written; one `organization.profile.updated` record carries the before/after of each changed field | `test/e2e/organization-profile-update.e2e-spec.ts › changes the name alone, answers the profile and records exactly that change`; `test/e2e/organization-profile-update.e2e-spec.ts › changes the locale alone and records exactly that change`; `test/e2e/organization-profile-update.e2e-spec.ts › changes the currency alone and records exactly that change`; `test/e2e/organization-profile-update.e2e-spec.ts › changes the three fields together in one record, in a fixed order`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › changes only the name and leaves locale, currency and code alone`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › changes only the locale, then only the currency`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › changes the three columns together`; `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › records exactly the fields that really changed, in a fixed order, and answers the new profile` |
| Attempt to change `code` through this route | Refused at the API layer: `400 foundation.validation_failed` naming `code` as an unrecognised field; nothing changes and no record is written | `test/e2e/organization-profile-update-input.e2e-spec.ts › names the code as the unrecognised field, so the client learns it cannot be edited`; `test/e2e/organization-profile-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › rejects %s`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › names only the code, not a missing field, when the code is the whole body`; `test/e2e/organization-profile-update.e2e-spec.ts › never changes the organization code`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › changes only the name and leaves locale, currency and code alone` |
| Caller lacks org-wide scope (no role, or a role scoped to one business unit, or a retired role) | Refused by the permission check, not by RLS: `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` committed with the refusal, nothing changed | `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses a member with no role: 403 organization.action_forbidden, one denied record, nothing changed`; `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses a member whose only role is scoped to a business unit`; `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses a member whose role carrying the permission was retired`; `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › records a denied audit, commits it, and only then refuses when the permission is missing`; `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › checks organization.account.manage for the tenant membership, organization-wide, before touching the row`; `src/modules/organization/presentation/failures/to-public-failure.spec.ts › answers a caller without the permission as forbidden, revealing nothing` |
| Unknown `default_locale` or `default_currency` | Refused by the FK, no silent fallback: `422 organization.profile_refused` with a detail per reason; the transaction rolls back, so the row is unchanged and no record exists | `test/e2e/organization-profile-update-input.e2e-spec.ts › refuses an unknown locale with 422 organization.profile_refused, and neither changes nor records anything`; `test/e2e/organization-profile-update-input.e2e-spec.ts › refuses an unknown currency with 422 organization.profile_refused, and neither changes nor records anything`; `test/e2e/organization-profile-update-input.e2e-spec.ts › leaves %s to the reference data: 422, never a silent fallback`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › refuses an unknown locale with its own reason and leaves the row untouched`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › refuses an unknown currency with its own reason and leaves the row untouched`; `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › lets a database refusal propagate untouched, with the work rolled back and nothing audited`; `src/modules/organization/presentation/failures/to-public-failure.spec.ts › answers every profile refusal reason as unprocessable content with a detail of its own`; `src/modules/organization/presentation/failures/to-public-failure.spec.ts › never leaks the technical cause of a profile refusal` |
| A member with an organization-wide custom role that carries the permission (not the owner role) | `200 OK`; the record names that member and membership: the permission decides, not the role name | `test/e2e/organization-profile-update-access.e2e-spec.ts › lets a member with an organization-wide custom role that carries the permission update, as the owner does` |
| Same values sent again, or a body equal to what is stored | `200 OK` with the current profile; nothing is written and no record exists (no change, no record) | `test/e2e/organization-profile-update.e2e-spec.ts › answers the same values twice: the second call writes nothing and records nothing`; `test/e2e/organization-profile-update.e2e-spec.ts › answers the current profile and records nothing when the body equals what is stored`; `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › answers the current profile and writes nothing when every value is already stored` |
| A body that repeats one stored value beside a real change, or leaves a field out | Only the fields whose value really differs are written and audited; an unchanged field never appears in the record | `test/e2e/organization-profile-update.e2e-spec.ts › records only the fields that really changed when the body repeats one stored value`; `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › never writes or audits a field whose new value equals the stored one`; `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › ignores a field the caller left out` |
| The audit record of a successful update | Sealed, with the actor identity, the membership, the organization and the organization as its target | `test/e2e/organization-profile-update.e2e-spec.ts › stamps the record with the actor, the membership, the organization and a seal` |
| The same forbidden member tries repeatedly | Every attempt leaves its own `denied` record, and no record ever carries the values it tried to write | `test/e2e/organization-profile-update-access.e2e-spec.ts › records a denied attempt each time it is made, and never the values it tried to write` |
| Body is empty, holds an unknown field, a NUL character, a blank or over-long value, or the wrong type | `400 foundation.validation_failed` with the field named; nothing changes and no record exists; name and locale limits are those of provisioning | `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › accepts %s`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › rejects %s`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › rejects %j as a whole body`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › asks for at least one field, and only that, when the body is empty`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › trims the name, exactly as provisioning does`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › accepts a name of exactly the limit and refuses one character more`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › accepts a locale of exactly the limit and refuses one character more`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › answers %s=%j exactly as provisioning does`; `test/e2e/organization-profile-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/organization-profile-update-input.e2e-spec.ts › answers 400 for a body that is an array, changing nothing` |
| Hostile text (unicode, RTL, emoji, quotes, semicolons, `%`, backslash) and a name of exactly 200 characters | Stored, returned and audited verbatim | `test/e2e/organization-profile-update.e2e-spec.ts › stores and returns hostile text verbatim, and audits it verbatim`; `test/e2e/organization-profile-update.e2e-spec.ts › accepts a name of exactly 200 characters and stores it whole`; `src/modules/organization/presentation/dtos/update-organization-profile-request.schema.spec.ts › keeps hostile text verbatim inside the limit` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`, identical for a foreign and an unknown organization; nothing changes and no record exists | `test/e2e/organization-profile-update-access.e2e-spec.ts › answers 401 identity.session_required without a session and changes nothing`; `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses a request without X-Organization-Id as a rejected tenant context`; `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses an X-Organization-Id that is %s as a rejected tenant context`; `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses the owner of another organization who names this one, and records nothing here`; `test/e2e/organization-profile-update-access.e2e-spec.ts › answers a foreign organization exactly as it answers one that does not exist`; `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses a member whose membership became %s after the token was issued`; `test/e2e/organization-profile-update-access.e2e-spec.ts › refuses even the owner once the organization is suspended` |
| Refusals never reveal SQL, tables, constraints or the permission code | Safe generic detail in every refusal | `test/e2e/organization-profile-update-access.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal`; `src/modules/organization/presentation/failures/to-public-failure.spec.ts › answers a caller without the permission as forbidden, revealing nothing` |
| Parallel `PATCH` requests on one organization | Serialised by `FOR UPDATE`: all answer `200`, every record's `before` is the `after` of the record before it, no field update is lost, identical renames write once | `test/e2e/organization-profile-update-concurrency.e2e-spec.ts › serialises parallel renames: all answer 200 and every record continues the one before it`; `test/e2e/organization-profile-update-concurrency.e2e-spec.ts › lets exactly one of several parallel identical renames write, and the rest answer 200 without a record`; `test/e2e/organization-profile-update-concurrency.e2e-spec.ts › loses no update when parallel requests change different fields`; `test/e2e/organization-profile-update-concurrency.e2e-spec.ts › never lets a refused parallel request undo or block the accepted ones`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › holds the row lock until the transaction ends, so a second reader waits` |
| The row of another organization | Invisible to the lock and untouched by an update that names its id (RLS) | `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › cannot see the row of another organization`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › leaves the row of another organization untouched, even when its id is named` |
| A database failure that is not the locale or currency foreign key | Never disguised as a refusal; it surfaces as itself and the row is unchanged | `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › does not disguise a database failure caused by %s as a refusal` |
| The organization row is gone or invisible once the transaction starts | Answered as a rejected tenant context, nothing written | `src/modules/organization/application/organization-profile/update-organization-profile.use-case.spec.ts › answers as a rejected tenant context, writing nothing, when the organization row is gone` |

### O6 — Suspend, reactivate, or archive organization

The actor is always Ninaku platform staff (control plane), owner decision of 2026-09-30: staff-only, reason required, archival one-way, archival guarded by a live elevation and an `Idempotency-Key`. The tenant-side suspension routes are superseded by SP17 and are not built. Suspension and reactivation shipped as SP17 (`platform.suspend_organization_as_staff`, `platform.reactivate_organization_as_staff`); archival is `platform.archive_organization_as_staff` behind `POST /platform-admin/organizations/{organizationId}/archival`.

| Case | Expected answer | Proving test |
|---|---|---|
| Suspend an active organization that keeps its administrator (staff only, SP17) | `204 No Content`, status `suspended`, the reason kept in the audit record | `test/e2e/platform-admin-organization-lifecycle.e2e-spec.ts › suspends an organization and records who did it` |
| Reactivate a suspended organization that still has an active org-wide administrator (staff only, SP17) | `204 No Content`, status `active` | `test/e2e/platform-admin-organization-lifecycle.e2e-spec.ts › reactivates a suspended organization and records who did it` |
| Adversarial: reactivate a suspended organization that lost its last administrator while suspended | Refused at commit, `23514 · identity_last_administrator_required` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-04 reactivating a suspended organization that lost its last administrator is refused` |
| Suspend an organization mid-provisioning (no Subscription yet) | Refused, `23514`, the row is left as it was | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-05 suspending an organization mid-provisioning is refused` |
| Archive an active organization (staff, reason, live elevation, `Idempotency-Key`) | `204 No Content`, status `archived`, exactly one `platform.organization.archived` record with outcome `success`, the reason, the staff actor and the organization as its only target | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › archives an active organization and records who did it, why and on what`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-01 staff archives an active organization` |
| Archive a suspended organization | `204 No Content`, status `archived` | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › archives a suspended organization`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-02 staff archives a suspended organization` |
| Archive an organization that has not completed provisioning (no Subscription yet) | `422 · platform.organization_command_refused`, status unchanged, one `denied` record with reason code `not_provisioned` | `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts › refuses an organization that has not completed provisioning, records the denied attempt and changes nothing`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-04 archiving an organization mid-provisioning is refused` |
| Archive an organization that is already archived | `422 · platform.organization_command_refused`, status unchanged, the original `success` record plus one `denied` record with reason code `already_archived`, no SQL or constraint name in the body | `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts › refuses an organization that is already archived, records the denied attempt and changes nothing`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-03 archiving an already-archived organization is refused` |
| Concurrency: several archivals of the same organization raced with different keys | Exactly one `204`, every other request `422`, one `success` record and one `denied` record per refused attempt | `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts › lets exactly one of several parallel archivals win and audits every attempt` |
| Archive an organization that does not exist | `404 · platform.organization_not_found`, no audit record | `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts › answers 404 for an organization that does not exist and records nothing` |
| Reason missing, empty, blank, NUL-bearing, over 500 characters, not a string, or accompanied by an unknown field | `400 · foundation.validation_failed`, status unchanged, no audit record | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses a $label reason with 400 and leaves nothing behind` |
| Hostile reason text (markup, SQL, template and log-injection payloads) | Stored verbatim, never interpreted, status `archived` | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › stores a hostile reason verbatim and never interprets it` |
| Staff member without a live elevation, or with an elevation token nobody issued | `403 · platform.elevation_required`, status unchanged, no audit record | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses a staff member who has no live elevation with 403 and changes nothing`; `test/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses an elevation token nobody issued with 403` |
| Caller who is not active platform staff | `403 · platform.staff_required`, the same body for a real and an unknown organization, no denied record | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › answers a non-staff caller the same for a real and an unknown organization, and records nothing`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-06 a non-staff identity cannot archive an organization` |
| No session | `401 · identity.session_required`, status unchanged | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses a request without a session with 401` |
| No `Idempotency-Key` | `400`, status unchanged, no audit record | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses a request without an Idempotency-Key and changes nothing` |
| Same key, same reason, retried | `204` again, one audit record | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › answers a repeated request with the same key and reason the same way, with one audit record`; `src/modules/platform/application/organization-administration/change-organization-status.use-case.spec.ts › replays the same key and reason without archiving or auditing a second time` |
| Same key reused with a different reason | `409 · idempotency.key_reused`, the original reason stays in the audit record | `test/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses to reuse a key with a different reason and keeps the original reason` |
| Archival is one-way: staff reactivation of an archived organization | `422 · platform.organization_command_refused`, status stays `archived` | `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts › refuses to reactivate an archived organization and leaves it archived`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-07 staff reactivation refuses an archived organization` |
| Archival is one-way: staff suspension of an archived organization | `422 · platform.organization_command_refused`, status stays `archived` | `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts › refuses to suspend an archived organization and leaves it archived`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-08 staff suspension refuses an archived organization` |
| Archival is one-way: a staff payment recorded for the archived organization's subscription | The payment renews the subscription but the organization stays `archived` | `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts › does not reactivate an archived organization when staff record a payment for its subscription`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-13 a staff payment on an archived organization renews its subscription but never reactivates the organization` |
| Archival is one-way: the lifecycle sweep and a direct `UPDATE` by any role, including the privileged one | The sweep leaves the organization `archived`; a direct move out of `archived` is refused `23514 · organizations_archived_terminal`, while an `UPDATE` that keeps it `archived` is not blocked | `database/tests/contracts/organization-archival-contracts.ts` › `AR-09 a direct UPDATE cannot move an archived organization back to active`; `database/tests/contracts/organization-archival-contracts.ts` › `AR-14 the lifecycle sweep lapses the subscription of an archived organization but leaves it archived` |
| Audit vocabulary: the archival action requires a reason | `platform.organization.archived` is `high` risk, scope `platform`, and refuses a record without a reason text or reason code | `database/tests/contracts/organization-archival-contracts.ts` › `AR-16 the audit vocabulary knows the archival action, which requires a reason` |
| Members: the session context of an archived organization | Nothing selected, no roles, no permissions, for the owner and for a member | `test/e2e/platform-admin-organization-archival-members.e2e-spec.ts › selects nothing in the session context, for the owner and for a member` |
| Members: `GET /organizations` | The archived organization is no longer listed | `test/e2e/platform-admin-organization-archival-members.e2e-spec.ts › no longer lists the organization among the ones the owner belongs to` |
| Members: `GET /subscription` with the archived organization's id | `403 · tenant.context_rejected`, no data | `test/e2e/platform-admin-organization-archival-members.e2e-spec.ts › refuses the subscription read with tenant.context_rejected, even to the owner` |
| Members: `PATCH /organization` on an archived organization | `403 · tenant.context_rejected`, the profile unchanged | `test/e2e/platform-admin-organization-archival-members.e2e-spec.ts › refuses a profile change with tenant.context_rejected and keeps the profile as it was` |

## Legal entity

### O7 — Register legal entity

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: a member with org-wide `organization.account.manage` registers a legal entity | `201 Created` with `{ legalEntityId, code, legalName, tradeName, incorporationCountry, status }`; the row is `active` under the tenant organization; one `organization.legal_entity.registered` record carries the created values as after-only entries | `test/e2e/legal-entities-register.e2e-spec.ts › answers 201 with the legal entity, stores it active under the organization and records one success record`; `src/modules/organization/application/legal-entities/register-legal-entity.use-case.spec.ts › stores an active legal entity under the tenant organization and answers it`; `src/modules/organization/application/legal-entities/register-legal-entity.use-case.spec.ts › records exactly one success record whose after values are the created ones, in a fixed order`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › stores the legal entity under the tenant organization with every value it was given`; `test/e2e/legal-entities-register.e2e-spec.ts › registers several legal entities with different codes in one organization, one record each` |
| `trade_name` is optional and may be null | Stored as null, answered as null and left out of the audit record | `test/e2e/legal-entities-register.e2e-spec.ts › registers without a trade name: stores null, answers null and leaves the trade name out of the record`; `test/e2e/legal-entities-register.e2e-spec.ts › registers with a null trade name exactly as without one`; `src/modules/organization/application/legal-entities/register-legal-entity.use-case.spec.ts › stores a missing trade name as null and leaves it out of the record`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › stores a missing trade name as null` |
| `incorporation_country` sent in lower or mixed case | Trimmed, read as two ASCII letters, stored, answered and audited in uppercase (the shared `CountryCodeSchema`); digits, dashes, non-ASCII letters and a wrong length are refused `400` | `test/e2e/legal-entities-register.e2e-spec.ts › reads the country %j and stores and answers %j`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › reads the country %j as %j`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › refuses a country whose uppercase form is not two characters` |
| Duplicate `code` within the same organization: exact, in another case, in mixed case or with surrounding whitespace | `409 organization.structure_code_taken`; one row and one record remain; the unique index `(organization_id, lower(code))` is the arbiter and the code is trimmed before it | `test/e2e/legal-entities-register-refusals.e2e-spec.ts › answers 409 organization.structure_code_taken for a code repeated %s, keeping one row and one record`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › refuses a code that differs only in case inside the same organization, with its own failure and one row`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › refuses the very same code inside the same organization`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › trims the code, the legal name and the trade name`; `src/modules/organization/presentation/failures/to-public-structure-failure.spec.ts › answers a taken code as a conflict of its own kind` |
| The same `code` in another organization | Allowed, in any case; uniqueness is per organization | `test/e2e/legal-entities-register-refusals.e2e-spec.ts › allows the same code in another organization, even in another case`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › allows the same code in another organization` |
| Unknown `incorporation_country` | `422 organization.structure_refused` with the detail `That country is not recognised.`; the transaction rolls back, no row and no record exist and the code stays free | `test/e2e/legal-entities-register-refusals.e2e-spec.ts › answers 422 organization.structure_refused for the country %j, storing and recording nothing`; `test/e2e/legal-entities-register-refusals.e2e-spec.ts › does not spend the code on a refused registration: the same code registers once the country is fixed`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › refuses an unknown incorporation country with its own reason and stores nothing`; `src/modules/organization/presentation/failures/to-public-structure-failure.spec.ts › answers every refusal reason as unprocessable content with a detail of its own` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | Refused by the permission check, not by RLS: `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the organization, committed with the refusal; nothing stored | `test/e2e/legal-entities-permission.e2e-spec.ts › refuses a member with no role: 403 organization.action_forbidden, one denied record against the organization, nothing stored`; `test/e2e/legal-entities-permission.e2e-spec.ts › refuses a member whose only role is scoped to a business unit, on both routes`; `test/e2e/legal-entities-permission.e2e-spec.ts › refuses a member whose role carrying the permission was retired, on both routes`; `src/modules/organization/application/legal-entities/register-legal-entity.use-case.spec.ts › records a denied record against the organization, commits it and only then refuses, storing nothing` |
| A member with an organization-wide custom role that carries the permission | `201`; the record names that member and membership: the permission decides, not the role name | `test/e2e/legal-entities-permission.e2e-spec.ts › lets a member with an organization-wide custom role that carries the permission register and update, as the owner does` |
| The same forbidden member tries repeatedly | Every attempt leaves its own `denied` record, and no record ever carries the values it tried to write | `test/e2e/legal-entities-permission.e2e-spec.ts › records a denied attempt each time, and never the values it tried to write` |
| Body is empty, lacks a required field, holds an unknown field, a NUL character, a blank or over-long value, or the wrong type | `400 foundation.validation_failed`; nothing stored and no record exists; code is at most 50 characters and names at most 250 | `test/e2e/legal-entities-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › refuses %s`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › refuses a body without %s`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › refuses %j as a whole body`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › accepts a code and names of exactly the limit and refuses one character more` |
| The caller smuggles `organizationId`, `status`, `id`, `legalEntityId` or any unknown field | `400` naming the field as unrecognised; the organization always comes from the tenant context and the status is always `active` | `test/e2e/legal-entities-register-input.e2e-spec.ts › refuses %s smuggled beside a valid body with 400 naming it, storing nothing`; `test/e2e/legal-entities-register-input.e2e-spec.ts › never lets the caller place a legal entity in another organization through the body`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › refuses %s smuggled beside valid fields`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › names only the unknown key when a valid body carries one` |
| Hostile text (unicode, RTL, emoji, quotes, semicolons, `%`, backslash) and a code of 50 and names of 250 characters | Stored, returned and audited verbatim | `test/e2e/legal-entities-register.e2e-spec.ts › keeps hostile text verbatim in the answer, the row and the record`; `test/e2e/legal-entities-register.e2e-spec.ts › accepts a code of 50 characters and names of 250 characters`; `test/e2e/legal-entities-register.e2e-spec.ts › trims the code and both names before storing them`; `src/modules/organization/presentation/dtos/register-legal-entity-request.schema.spec.ts › keeps hostile text verbatim inside the limit` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing stored and no record exists | `test/e2e/legal-entities-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses a request without X-Organization-Id as a rejected tenant context on both routes`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses an X-Organization-Id that is %s as a rejected tenant context, storing and recording nothing`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses a member whose membership became %s after the token was issued, on both routes`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel registrations of one code | Exactly one `201`, the rest `409`; one row and one record; holds for variants in case and whitespace | `test/e2e/legal-entities-concurrency.e2e-spec.ts › lets exactly one of several parallel registrations of the same code win: one 201, the rest 409, one row, one record`; `test/e2e/legal-entities-concurrency.e2e-spec.ts › holds the same for parallel registrations that differ only in case and whitespace`; `test/e2e/legal-entities-concurrency.e2e-spec.ts › registers parallel legal entities with different codes all at once, one record each` |
| Refusals never reveal SQL, tables, constraints or the permission code | Safe generic detail in every refusal | `test/e2e/legal-entities-register-refusals.e2e-spec.ts › never reveals SQL, tables or constraints in a conflict or a refusal`; `test/e2e/legal-entities-permission.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal`; `src/modules/organization/presentation/failures/to-public-structure-failure.spec.ts › never leaks the technical cause of %s` |
| A database failure that is neither the code index nor the country foreign key; a write aimed at another organization | Never disguised as a structure failure; it surfaces as itself, nothing is stored, and a write into another organization is refused | `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › does not disguise a database failure caused by %s as a structure failure`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › never stores a legal entity in another organization, and does not disguise that failure`; `src/modules/organization/application/legal-entities/register-legal-entity.use-case.spec.ts › lets %s from the repository propagate untouched, rolled back and unaudited` |
| Audit vocabulary and the runtime grants | The two tenant action types and the `organization.legal_entity` resource kind exist with the expected flags; the runtime role inserts and updates only inside its tenant, never deletes, cannot move a row to another organization, and the code and country constraints hold | `database/tests/prepare/organization-legal-entity-contracts.ts › theVocabularyKnowsBothActionsAndTheResourceKind`; `database/tests/prepare/organization-legal-entity-contracts.ts › runtimeRecordsAndReadsBothActions`; `database/tests/prepare/organization-legal-entity-contracts.ts › runtimeKeepsLegalEntitiesInsideItsTenant` |

### O8 — Update legal entity profile

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: rename `legal_name` and/or `trade_name` | `200 OK` with the legal entity; only real changes are written; one `organization.legal_entity.updated` record carries the before and after of each changed field, legal name first | `test/e2e/legal-entities-update.e2e-spec.ts › changes the legal name alone, answers the legal entity and records exactly that change`; `test/e2e/legal-entities-update.e2e-spec.ts › changes the trade name alone and records exactly that change`; `test/e2e/legal-entities-update.e2e-spec.ts › changes both names together in one record, legal name first`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › records exactly the fields that really changed, legal name first, and answers the new legal entity`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › keeps the stored trade name when only the legal name is sent`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › keeps the stored legal name when only the trade name is sent` |
| Clear the trade name with `null`, or set a first one | `200`; the stored value becomes null (or the new name) and the record shows null as after (or as before) | `test/e2e/legal-entities-update.e2e-spec.ts › clears the trade name with null: stores null, answers null and records the old value as before and nothing as after`; `test/e2e/legal-entities-update.e2e-spec.ts › sets a first trade name on a legal entity registered without one`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › clears the trade name when null is sent, recording its old value as before and null as after`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › records a first trade name with null as before`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › clears the trade name when given null` |
| Attempt to change `code`, `incorporation_country`, `status`, `organizationId` or any unknown field | Refused at the API layer: `400 foundation.validation_failed` naming the field as unrecognised; nothing changes and no record is written | `test/e2e/legal-entities-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/legal-entities-update-input.e2e-spec.ts › names %s as the unrecognised field, so the client learns it cannot be edited`; `test/e2e/legal-entities-update-input.e2e-spec.ts › leaves the immutable fields as registered when a mixed body is refused`; `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › rejects %s`; `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › names only the immutable field, not a missing one, when it is the whole body`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › changes both names and never the code, the country, the status or the organization` |
| Legal entity belongs to another organization, or does not exist | `404 organization.structure_not_found`, identical for both apart from the echoed path and trace; RLS makes the row invisible, never `403`, and existence is never confirmed; nothing changes and no record exists | `test/e2e/legal-entities-update.e2e-spec.ts › answers 404 organization.structure_not_found for an identifier nobody registered, recording nothing`; `test/e2e/legal-entities-update.e2e-spec.ts › answers 404 for the legal entity of another organization exactly as for one that does not exist, changing nothing`; `test/e2e/legal-entities-update.e2e-spec.ts › answers 200 to the organization that owns the legal entity while another organization still gets 404 for its identifier`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › answers not found, writing and auditing nothing, when the legal entity is not visible`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › cannot see the legal entity of another organization`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › leaves the legal entity of another organization untouched, even when its id is named`; `src/modules/organization/presentation/failures/to-public-structure-failure.spec.ts › answers an element that is not visible as not found of its own kind` |
| The path identifier is not a UUID, or is an uppercase UUID | `400` naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercase | `test/e2e/legal-entities-update-input.e2e-spec.ts › answers 400 for a path identifier that is %s, before any lookup`; `test/e2e/legal-entities-update.e2e-spec.ts › accepts the identifier in uppercase, acts on the same row and records the canonical identifier`; `src/modules/organization/presentation/dtos/legal-entity-id-param.schema.spec.ts › reads an uppercase UUID as its lowercase canonical form`; `src/modules/organization/presentation/dtos/legal-entity-id-param.schema.spec.ts › refuses %s` |
| Same values sent again, or a body equal to what is stored | `200 OK` with the current legal entity; nothing is written, `updated_at` does not move and no record exists | `test/e2e/legal-entities-update.e2e-spec.ts › answers 200 with the current legal entity and records nothing when sent the values already stored, twice`; `test/e2e/legal-entities-update.e2e-spec.ts › moves updated_at only when something really changed`; `test/e2e/legal-entities-update.e2e-spec.ts › records a change once: repeating it answers 200 and adds no second record`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › answers the stored legal entity, writing and auditing nothing, for %s`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › treats a null trade name sent for a legal entity without one as no change` |
| A body that repeats one stored value beside a real change | Only the fields whose value really differs are audited; an unchanged field never appears in the record | `test/e2e/legal-entities-update.e2e-spec.ts › never audits a field that was sent unchanged beside one that changed`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › never writes or audits a field whose new value equals the stored one` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the legal entity, committed with the refusal; the permission is checked before the row is locked, so a member without it gets the same answer whether or not the legal entity exists | `test/e2e/legal-entities-permission.e2e-spec.ts › refuses a member with no role on update: one denied record against the legal entity, nothing changed`; `test/e2e/legal-entities-permission.e2e-spec.ts › answers a member without the permission the same way whether the legal entity exists or not`; `test/e2e/legal-entities-permission.e2e-spec.ts › refuses a member whose only role is scoped to a business unit, on both routes`; `test/e2e/legal-entities-permission.e2e-spec.ts › refuses a member whose role carrying the permission was retired, on both routes`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › records a denied record against the legal entity, commits it and only then refuses, without locking`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › checks the permission before it locks the row, and locks the row before it updates it` |
| The permission check and denied audit shared by O5, O7 and O8 | One function asks `organization.account.manage` of the tenant membership and records exactly one `denied` record for the asked action and target when it is missing | `src/modules/organization/application/organization-access.spec.ts › asks for organization.account.manage of the tenant membership, organization-wide`; `src/modules/organization/application/organization-access.spec.ts › answers yes and records nothing when the permission is held`; `src/modules/organization/application/organization-access.spec.ts › answers no and records exactly one denied record for the asked action and target when it is missing` |
| A member with an organization-wide custom role that carries the permission | `200`; the record names that member and membership | `test/e2e/legal-entities-permission.e2e-spec.ts › lets a member with an organization-wide custom role that carries the permission register and update, as the owner does` |
| Body is empty, holds a NUL character, a blank or over-long value, or the wrong type | `400 foundation.validation_failed`; nothing changes and no record exists; `null` is accepted only for the trade name | `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › accepts %s`; `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › rejects %j as a whole body`; `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › asks for at least one field, and only that, when the body is empty`; `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › keeps a null trade name as null, so that the use case can tell clearing from leaving out`; `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › trims both names and accepts exactly the limit`; `src/modules/organization/presentation/dtos/update-legal-entity-request.schema.spec.ts › keeps hostile text verbatim inside the limit` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing changes and no record exists | `test/e2e/legal-entities-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses a request without X-Organization-Id as a rejected tenant context on both routes`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses a member whose membership became %s after the token was issued, on both routes`; `test/e2e/legal-entities-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel `PATCH` requests on one legal entity | Serialised by `FOR UPDATE`: all answer `200`, every record's `before` is the `after` of the record before it, no field update is lost, identical renames write once | `test/e2e/legal-entities-concurrency.e2e-spec.ts › serialises parallel renames: all answer 200 and every record continues the one before it`; `test/e2e/legal-entities-concurrency.e2e-spec.ts › lets exactly one of several parallel identical renames write, and the rest answer 200 without a record`; `test/e2e/legal-entities-concurrency.e2e-spec.ts › loses no update when parallel requests change different fields`; `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › holds the row lock until the transaction ends, so a second reader waits` |
| A database failure while updating | Never disguised as a structure failure: the repository lets it surface as itself, and the use case neither catches it nor audits (the audit call comes after the write, so a failed write leaves no record). Not reachable over HTTP, because the request schema caps the name below the column size, so it is proven at the repository and use case layers only | `src/modules/organization/infrastructure/legal-entities/legal-entity.repository.integration-spec.ts › does not disguise a legal name longer than its column as a structure failure`; `src/modules/organization/application/legal-entities/update-legal-entity.use-case.spec.ts › lets a database failure propagate untouched, rolled back and unaudited` |

## Business unit

### O9 — Register business unit

| Case | Expected answer | Proving test |
|---|---|---|
| Optional `verticalCodes` (D1) | Each vertical is turned on through `BusinessUnitVerticalsContract.activate` after the new unit is locked, in the registering transaction; one registration record, then one `organization.business_unit.vertical_activated` record per vertical | `test/e2e/business-unit-register-verticals.e2e-spec.ts › turns each requested vertical on in the registering transaction and records the registration and one activation per vertical`; `src/modules/organization/application/business-units/register-business-unit-verticals.use-case.spec.ts › locks the new unit and activates each vertical through Platform inside the registering transaction`; `src/modules/organization/application/business-units/register-business-unit-verticals.use-case.spec.ts › records the registration and then one first activation per vertical`; `src/modules/organization/application/business-units/register-business-unit-verticals.use-case.spec.ts › never locks the unit nor calls Platform when no vertical is requested` |
| An unknown, deprecated or retired vertical in `verticalCodes` | `422 organization.vertical_unavailable`; no unit, no vertical and no record is stored | `test/e2e/business-unit-register-verticals.e2e-spec.ts › refuses %s with 422 and stores neither the unit nor any vertical nor any record`; `src/modules/organization/application/business-units/register-business-unit-verticals.use-case.spec.ts › refuses an unavailable vertical and rolls the whole registration back` |
| A repeated, malformed or 21st vertical code | `400 foundation.validation_failed` | `test/e2e/business-unit-register-verticals.e2e-spec.ts › refuses %s with 400`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › refuses verticalCodes with %s`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › accepts verticalCodes with %s` |
| Two registrations with the same new vertical raced in one organization | Both `201`; they decide their assortment one after the other, so exactly one starts at `none` and the other at `all` | `test/e2e/business-unit-register-verticals.e2e-spec.ts › serialises two registrations raced in one organization, so only one of them finds the vertical already running` |
| Happy path: a member with org-wide `organization.account.manage` registers a business unit | `201 Created` with `{ businessUnitId, code, name, status }`; the row is `active` under the tenant organization; one `organization.business_unit.registered` record carries the code and the name as after-only entries | `test/e2e/business-units-register.e2e-spec.ts › answers 201 with the business unit, stores it active under the organization and records one success record`; `src/modules/organization/application/business-units/register-business-unit.use-case.spec.ts › stores an active business unit under the tenant organization and answers it`; `src/modules/organization/application/business-units/register-business-unit.use-case.spec.ts › records exactly one success record whose after values are the created ones, code first`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › stores the business unit under the tenant organization with every value it was given` |
| `code` must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$` (Code violates the character-class check) | A code starting with a letter or a digit and made of letters, digits, `.`, `_` and `-` up to 80 characters is accepted, kept in the case given; any other code is refused `400 foundation.validation_failed` and nothing is stored or recorded | `test/e2e/business-units-register.e2e-spec.ts › accepts a code holding %s and stores it as given`; `test/e2e/business-units-register.e2e-spec.ts › keeps the case of the code it was given`; `test/e2e/business-units-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › accepts a code made of %s`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › refuses a malformed code: %s`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › does not disguise a database failure caused by %s as a taken code` |
| Duplicate `code` within the same organization: exact, in another case, in mixed case or with surrounding whitespace | `409 organization.structure_code_taken`; one row and one record remain; the unique index `(organization_id, lower(code))` is the arbiter and the code is trimmed before it | `test/e2e/business-units-register-refusals.e2e-spec.ts › answers 409 organization.structure_code_taken for a code repeated %s, keeping one row and one record`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › refuses a code that differs only in case inside the same organization, with its own failure and one row`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › refuses the very same code inside the same organization` |
| The code `principal` that provisioning gave the organization's first business unit | `409 organization.structure_code_taken` in any case or with whitespace; the provisioned business unit stays as it was and no record exists | `test/e2e/business-units-register-refusals.e2e-spec.ts › answers 409 for the code %j, which provisioning already gave the organization its first business unit`; `test/e2e/business-units-register.e2e-spec.ts › leaves the business unit that provisioning created exactly as it was` |
| The same `code` in another organization | Allowed, in any case; uniqueness is per organization | `test/e2e/business-units-register-refusals.e2e-spec.ts › allows the same code in another organization, even in another case`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › allows the same code in another organization` |
| The caller smuggles `organizationId`, `status`, `id`, `businessUnitId`, `parentId` or any unknown field | `400` naming the field as unrecognised; the organization always comes from the tenant context and the status is always `active` (the table has no parent column: the structure is flat) | `test/e2e/business-units-register-input.e2e-spec.ts › refuses %s smuggled beside a valid body with 400 naming it, storing nothing`; `test/e2e/business-units-register-input.e2e-spec.ts › never lets the caller place a business unit in another organization through the body`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › refuses %s smuggled beside valid fields`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › names only the unknown key when a valid body carries one` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | Refused by the permission check, not by RLS: `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the organization, committed with the refusal; nothing stored | `test/e2e/business-units-permission.e2e-spec.ts › refuses a member with no role: 403 organization.action_forbidden, one denied record against the organization, nothing stored`; `test/e2e/business-units-permission.e2e-spec.ts › refuses a member whose only role is scoped to a business unit, on both routes`; `test/e2e/business-units-permission.e2e-spec.ts › refuses a member whose role carrying the permission was retired, on both routes`; `src/modules/organization/application/business-units/register-business-unit.use-case.spec.ts › records a denied record against the organization, commits it and only then refuses, storing nothing`; `src/modules/organization/application/business-units/register-business-unit.use-case.spec.ts › checks the permission before it writes, and commits the audit record after the write` |
| A member with an organization-wide custom role that carries the permission | `201`; the record names that member and membership: the permission decides, not the role name | `test/e2e/business-units-permission.e2e-spec.ts › lets a member with an organization-wide custom role that carries the permission register and rename, as the owner does` |
| The same forbidden member tries repeatedly | Every attempt leaves its own `denied` record, and no record ever carries the values it tried to write | `test/e2e/business-units-permission.e2e-spec.ts › records a denied attempt each time, and never the values it tried to write` |
| Body is empty, lacks a required field, holds an unknown field, a NUL character, a blank or over-long value, or the wrong type | `400 foundation.validation_failed`; nothing stored and no record exists; code is at most 80 characters and the name at most 200 | `test/e2e/business-units-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › refuses a body without %s`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › refuses a malformed name: %s`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › refuses %j as a whole body` |
| Hostile text (unicode, RTL, emoji, quotes, semicolons, `%`, backslash) in the name, surrounding whitespace, and names of 200 characters | Stored, returned and audited verbatim; code and name are trimmed first | `test/e2e/business-units-register.e2e-spec.ts › keeps hostile text in the name verbatim in the answer, the row and the record`; `test/e2e/business-units-register.e2e-spec.ts › trims the code and the name before storing them`; `src/modules/organization/presentation/dtos/register-business-unit-request.schema.spec.ts › trims the code and the name, and accepts exactly the limits` |
| Several registrations with different codes in one organization | Each answers `201` and leaves its own row and its own record; the identifier is new every time | `test/e2e/business-units-register.e2e-spec.ts › registers several business units with different codes in one organization, one record each`; `src/modules/organization/application/business-units/register-business-unit.use-case.spec.ts › gives every registration an identifier of its own` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing stored and no record exists | `test/e2e/business-units-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/business-units-tenant-context.e2e-spec.ts › refuses a request without X-Organization-Id as a rejected tenant context on both routes`; `test/e2e/business-units-tenant-context.e2e-spec.ts › refuses an X-Organization-Id that is %s as a rejected tenant context, storing and recording nothing`; `test/e2e/business-units-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/business-units-tenant-context.e2e-spec.ts › refuses a member whose membership became %s after the token was issued, on both routes`; `test/e2e/business-units-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel registrations of one code | Exactly one `201`, the rest `409`; one row and one record; holds for variants in case and whitespace; parallel registrations of different codes all succeed | `test/e2e/business-units-concurrency.e2e-spec.ts › lets exactly one of several parallel registrations of the same code win: one 201, the rest 409, one row, one record`; `test/e2e/business-units-concurrency.e2e-spec.ts › holds the same for parallel registrations that differ only in case and whitespace`; `test/e2e/business-units-concurrency.e2e-spec.ts › registers parallel business units with different codes all at once, one record each` |
| Refusals never reveal SQL, tables, constraints or the permission code | Safe generic detail in every refusal | `test/e2e/business-units-register-refusals.e2e-spec.ts › never reveals SQL, tables or constraints in a conflict`; `test/e2e/business-units-permission.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal` |
| A database failure that is not the code index; a write aimed at another organization | Never disguised as a structure failure; it surfaces as itself, nothing is stored, and a write into another organization is refused | `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › does not disguise a database failure caused by %s as a taken code`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › never stores a business unit in another organization, and does not disguise that failure`; `src/modules/organization/application/business-units/register-business-unit.use-case.spec.ts › lets %s from the repository propagate untouched, rolled back and unaudited` |
| Audit vocabulary and the runtime grants | The two tenant action types and the resource kind exist with the expected flags; the runtime role inserts, updates and reads only inside its tenant, never deletes, cannot move a row to another organization, a code differing only in case is refused, and a business unit is inserted by the runtime role with no vertical activation (registering never depends on O11) | `database/tests/prepare/organization-business-unit-brand-contracts.ts › theVocabularyKnowsTheFourActionsAndTheTwoResourceKinds`; `database/tests/prepare/organization-business-unit-brand-contracts.ts › runtimeRecordsAndReadsBothActions`; `database/tests/prepare/organization-business-unit-brand-contracts.ts › runtimeKeepsRowsInsideItsTenant` |

### O10 — Update business unit profile

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: rename | `200 OK` with the business unit; one `organization.business_unit.updated` record carries the old name as before and the new one as after | `test/e2e/business-units-update.e2e-spec.ts › renames the business unit, answers it and records exactly the name change`; `src/modules/organization/application/business-units/update-business-unit.use-case.spec.ts › writes the new name, answers the renamed business unit and records the name change with its old value as before`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › changes the name and never the code, the status or the organization` |
| Only the row named is changed | The other business units of the organization stay as registered and no record names them | `test/e2e/business-units-update.e2e-spec.ts › changes only the business unit it names and leaves the others of the organization as registered`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › changes only the row it names and leaves the other business units of the organization alone` |
| The business unit that provisioning created | Renamed like any other | `test/e2e/business-units-update.e2e-spec.ts › renames the business unit that provisioning created, like any other` |
| Attempt to change `code`, `status`, `organizationId` or any unknown field | Refused at the API layer: `400 foundation.validation_failed` naming the field as unrecognised; nothing changes and no record is written | `test/e2e/business-units-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/business-units-update-input.e2e-spec.ts › names %s as the unrecognised field, so the client learns it cannot be edited`; `test/e2e/business-units-update-input.e2e-spec.ts › leaves the code and the status as registered when a mixed body is refused`; `src/modules/organization/presentation/dtos/update-business-unit-request.schema.spec.ts › rejects %s` |
| Business unit belongs to another organization, or does not exist | `404 organization.structure_not_found`, identical for both apart from the echoed path and trace; RLS makes the row invisible, never `403`; nothing changes and no record exists | `test/e2e/business-units-update.e2e-spec.ts › answers 404 organization.structure_not_found for an identifier nobody registered, recording nothing`; `test/e2e/business-units-update.e2e-spec.ts › answers 404 for the business unit of another organization exactly as for one that does not exist, changing nothing`; `test/e2e/business-units-update.e2e-spec.ts › answers 200 to the organization that owns the business unit while another organization still gets 404 for its identifier`; `src/modules/organization/application/business-units/update-business-unit.use-case.spec.ts › answers not found, writing and auditing nothing, when the business unit is not visible`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › cannot see the business unit of another organization`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › leaves the business unit of another organization untouched, even when its id is named` |
| The path identifier is not a UUID, or is an uppercase UUID | `400` naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercase | `test/e2e/business-units-update-input.e2e-spec.ts › answers 400 for a path identifier that is %s, before any lookup`; `test/e2e/business-units-update.e2e-spec.ts › accepts the identifier in uppercase, acts on the same row and records the canonical identifier`; `src/modules/organization/presentation/dtos/business-unit-id-param.schema.spec.ts › reads an uppercase UUID as its lowercase canonical form` |
| The same name sent again | `200 OK` with the current business unit; nothing is written, `updated_at` does not move and no record exists; a repeated change records once | `test/e2e/business-units-update.e2e-spec.ts › answers 200 with the current business unit and records nothing when sent the name already stored, twice`; `test/e2e/business-units-update.e2e-spec.ts › moves updated_at only when the name really changed`; `test/e2e/business-units-update.e2e-spec.ts › records a rename once: repeating it answers 200 and adds no second record`; `src/modules/organization/application/business-units/update-business-unit.use-case.spec.ts › answers the stored business unit, writing and auditing nothing, for %s` |
| A name that differs only in case | A real change: written, answered and audited | `test/e2e/business-units-update.e2e-spec.ts › treats a name that differs only in case as a real change`; `src/modules/organization/application/business-units/update-business-unit.use-case.spec.ts › treats a name that differs only in case as a real change` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the business unit, committed with the refusal; the permission is checked before the row is locked, so a member without it gets the same answer whether or not the business unit exists | `test/e2e/business-units-permission.e2e-spec.ts › refuses a member with no role on rename: one denied record against the business unit, nothing changed`; `test/e2e/business-units-permission.e2e-spec.ts › answers a member without the permission the same way whether the business unit exists or not`; `src/modules/organization/application/business-units/update-business-unit.use-case.spec.ts › records a denied record against the business unit, commits it and only then refuses, without locking`; `src/modules/organization/application/business-units/update-business-unit.use-case.spec.ts › checks the permission before it locks the row, and locks the row before it updates it` |
| Body is empty, holds a NUL character, a blank or over-long name, or the wrong type | `400 foundation.validation_failed`; nothing changes and no record exists; the name is at most 200 characters | `test/e2e/business-units-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `src/modules/organization/presentation/dtos/update-business-unit-request.schema.spec.ts › rejects %s`; `src/modules/organization/presentation/dtos/update-business-unit-request.schema.spec.ts › rejects %j as a whole body`; `src/modules/organization/presentation/dtos/update-business-unit-request.schema.spec.ts › asks for the name, and only that, when the body is empty` |
| Hostile text in the name and surrounding whitespace | Trimmed, stored and returned verbatim | `test/e2e/business-units-update.e2e-spec.ts › trims the name and keeps hostile text verbatim`; `src/modules/organization/presentation/dtos/update-business-unit-request.schema.spec.ts › keeps hostile text verbatim inside the limit` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing changes and no record exists | `test/e2e/business-units-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/business-units-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/business-units-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel `PATCH` requests on one business unit | Serialised by `FOR UPDATE`: all answer `200`, every record's `before` is the `after` of the record before it, identical renames write once | `test/e2e/business-units-concurrency.e2e-spec.ts › serialises parallel renames: all answer 200 and every record continues the one before it`; `test/e2e/business-units-concurrency.e2e-spec.ts › lets exactly one of several parallel identical renames write, and the rest answer 200 without a record`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › holds the row lock until the transaction ends, so a second reader waits` |
| A database failure while updating | Never disguised as a structure failure and never audited: the use case neither catches it nor records, because the audit call comes after the write. Not reachable over HTTP, because the request schema caps the name below the column size | `src/modules/organization/application/business-units/update-business-unit.use-case.spec.ts › lets a database failure propagate untouched, rolled back and unaudited`; `src/modules/organization/infrastructure/business-units/business-unit.repository.integration-spec.ts › does not disguise a name longer than its column as a taken code` |

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

Both routes are served by `ChangeBusinessUnitVerticalUseCase` which since K10c reads and writes the activations only through Platform's `BusinessUnitVerticalsContract` (`activate` and `deactivate`, in the same tenant transaction as the audit record); they are proven by the unit specs of both modules, Platform's `platform-business-unit-vertical-changes.repository.integration-spec.ts` and `platform-business-unit-vertical-locks.repository.integration-spec.ts` (repository against PostgreSQL) and the `business-unit-verticals-*` e2e specs (HTTP); the database layer by `organization-business-unit-vertical-contracts.ts`.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: activate a vertical for one business unit that has none of it | `201 Created` with `{ businessUnitId, verticalCode, status }`, the row `active` with no `disabled_at` under the tenant organization; one `organization.business_unit.vertical_activated` record whose only change is `organization.business_unit.vertical.<code>` from no before value to `active`, naming the actor and the membership, sealed, against the business unit and carrying nothing of another organization | `test/e2e/business-unit-verticals-activate.e2e-spec.ts › activates a vertical on a registered business unit: 201, the stored row, and one audit record with no before value`; `test/e2e/business-unit-verticals-activate.e2e-spec.ts › activates another vertical on the unit that provisioning created, next to the first one`; `src/modules/organization/application/business-units/change-business-unit-vertical-activation.use-case.spec.ts › activates a first activation through Platform, answers it as created and records it with no before value`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › stores an active activation under the tenant organization` |
| Reactivating an `inactive` row | `200 OK`, `status = 'active'`, `disabled_at` cleared and `enabled_at` unchanged (the first activation); one record with `inactive` as the before value | `test/e2e/business-unit-verticals-activate.e2e-spec.ts › turns a deactivated vertical back on with 200, keeping the first enabled_at and recording inactive as before`; `src/modules/organization/application/business-units/change-business-unit-vertical-activation.use-case.spec.ts › turns an inactive activation back on, answers it as not created and records inactive as before`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › turns an activation off with a disabled_at and back on clearing it, keeping the first enabled_at` |
| Same organization, second business unit keeps a different vertical mix | Both states coexist independently: activating or deactivating on one unit never touches a sibling | `test/e2e/business-unit-verticals-activate.e2e-spec.ts › lets two business units of one organization run the same vertical independently`; `test/e2e/business-unit-verticals-activate.e2e-spec.ts › changes only the business unit it names: a sibling of the same organization keeps its verticals`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › changes only the activation it names` |
| Activating an unknown vertical, one `deprecated` or `retired`, on a business unit that is `inactive` or `archived`, or repeating an activation whose vertical was since deprecated | `422 · organization.vertical_unavailable`, the same answer for an unknown and a retired code; nothing stored and no record; the backstops (`platform_business_unit_vertical_active_context`, `business_unit_verticals_vertical_fk`) answer the same failure in the repository | `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › refuses a %s vertical: 422 organization.vertical_unavailable, nothing stored, nothing recorded`; `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › refuses a vertical code the catalog does not know, with the same answer as a retired one`; `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › refuses to repeat an activation once its vertical was deprecated, leaving the row active`; `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › refuses a business unit that is %s: 422 organization.vertical_unavailable, nothing stored`; `src/modules/platform/application/business-unit-verticals/change-business-unit-vertical-status.use-case.spec.ts › refuses a %s vertical as unavailable, even when the unit already has it active, writing and auditing nothing`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › answers %s as unavailable and stores nothing`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › answers a vertical the catalog does not know as unavailable`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › answers an activation on a business unit that is not active as unavailable` |
| Duplicate or raced activation of the same `(business_unit_id, vertical_code)` | `200`, one row and one record: repeating answers `200` and writes nothing; of several parallel activations exactly one answers `201`; the unit row lock serialises them, so the unique constraint is never reached over HTTP and, if it were, the repository would not disguise it | `test/e2e/business-unit-verticals-activate.e2e-spec.ts › answers 200 and writes nothing when the vertical is already active, twice: no second record, same row`; `test/e2e/business-unit-verticals-concurrency.e2e-spec.ts › lets exactly one of several parallel activations of the same vertical create it: one 201, the rest 200, one row, one record`; `src/modules/organization/application/business-units/change-business-unit-vertical-activation.use-case.spec.ts › answers an activation that is already active without writing or auditing anything`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › does not disguise a second activation of the same pair as an unavailable vertical` |
| Deactivate an active vertical while the organization keeps another | `200 OK`, `status = 'inactive'`, `disabled_at` set; one `organization.business_unit.vertical_deactivated` record with `active` as before and `inactive` as after, naming the actor and the membership | `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › turns one of two active verticals off: 200, the row inactive with its disabled_at, and one record with active as before`; `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › records every real change of a deactivate and reactivate cycle, one record each`; `src/modules/organization/application/business-units/change-business-unit-vertical-deactivation.use-case.spec.ts › turns an active activation off, answers it inactive and records active as before` |
| Deactivating an activation that is already inactive, never existed, or names a code the catalog does not know | `200`, `status = 'inactive'`; no row is created, nothing is written and no record exists | `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › answers 200 and writes nothing when the vertical is already inactive, twice: no second record, same row`; `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › answers 200 inactive and creates no row for %s, recording nothing`; `src/modules/platform/application/business-unit-verticals/change-business-unit-vertical-status.use-case.spec.ts › answers an activation that is %s as inactive without locking, writing or auditing anything` |
| Deactivating an active vertical that the catalog has since deprecated | Still works: `200`, the row inactive | `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › still turns off an active vertical that the catalog has since deprecated` |
| Deactivating on a business unit that is `inactive` or `archived` | `422 · organization.vertical_unavailable`; the row stays as it was and no record exists | `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › refuses a business unit that is %s: 422 organization.vertical_unavailable, the row untouched`; `src/modules/organization/application/business-units/change-business-unit-vertical-deactivation.use-case.spec.ts › refuses to deactivate on a %s business unit as unavailable` |
| Adversarial: a member without `organization.account.manage` organization-wide (no role, a role scoped to one business unit, a retired role) | `403 · organization.action_forbidden` on both routes, one `denied` record with reason `permission_missing` against the business unit, committed with the refusal and carrying no value; the permission is checked before the row is locked, so a member without it gets the same answer whether or not the business unit exists | `test/e2e/business-unit-verticals-permission.e2e-spec.ts › refuses a member with no role on activation: 403, one denied record against the business unit, nothing stored`; `test/e2e/business-unit-verticals-permission.e2e-spec.ts › refuses a member with no role on deactivation: 403, one denied record, the vertical still active`; `test/e2e/business-unit-verticals-permission.e2e-spec.ts › answers a member without the permission the same way whether the business unit exists or not`; `test/e2e/business-unit-verticals-permission.e2e-spec.ts › refuses a member whose only role is scoped to a business unit, on both routes`; `test/e2e/business-unit-verticals-permission.e2e-spec.ts › refuses a member whose role carrying the permission was retired`; `src/modules/organization/application/business-units/change-business-unit-vertical-activation.use-case.spec.ts › records a denied record against the business unit, commits it and only then refuses, without locking`; `src/modules/organization/application/business-units/change-business-unit-vertical-activation.use-case.spec.ts › checks the permission before it locks the unit, and locks the unit before it reads or writes the activation` |
| A member with an organization-wide custom role that carries the permission | `201` then `200`; the records name that member and membership: the permission decides, not the role name | `test/e2e/business-unit-verticals-permission.e2e-spec.ts › lets a member with an organization-wide custom role that carries the permission activate and deactivate, as the owner does` |
| Adversarial: another tenant's or an unknown business unit | `404 · organization.structure_not_found`, the same answer for both on both routes (RLS makes them one); the foreign unit keeps its verticals and neither organization gets a record | `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › answers 404 for the business unit of another organization exactly as for one that does not exist, changing nothing`; `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › answers 404 for the business unit of another organization exactly as for one that does not exist, changing nothing`; `src/modules/organization/application/business-units/change-business-unit-vertical-activation.use-case.spec.ts › answers not found, writing and auditing nothing, when the business unit is not visible`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › cannot read the activation of another organization`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › leaves the activation of another organization untouched, even when its pair is named` |
| Deactivate the organization's last active business-unit vertical, counted per organization across its active business units | `409 · organization.last_vertical_required`; the row stays `active` and no record exists; a unit may lose its only vertical while another unit keeps one; an inactive activation, another organization's verticals and an active activation inside an inactive unit never count as remaining | `test/e2e/business-unit-verticals-last-vertical.e2e-spec.ts › refuses to turn off the last active vertical of the organization: 409, still active, no record`; `test/e2e/business-unit-verticals-last-vertical.e2e-spec.ts › counts the whole organization, not the unit: a unit may lose its only vertical while another unit keeps one, and then the last one is refused`; `test/e2e/business-unit-verticals-last-vertical.e2e-spec.ts › does not count an inactive activation of another unit as a remaining vertical`; `test/e2e/business-unit-verticals-last-vertical.e2e-spec.ts › does not count the active verticals of another organization as a remaining vertical`; `test/e2e/business-unit-verticals-last-vertical.e2e-spec.ts › does not count an active activation that sits in an inactive business unit as a remaining vertical`; `src/modules/organization/application/business-units/change-business-unit-vertical-deactivation.use-case.spec.ts › refuses to turn off the last active vertical of the organization, writing and auditing nothing`; `src/modules/platform/application/business-unit-verticals/change-business-unit-vertical-status.use-case.spec.ts › counts the activations of other business units but never its own, so a second unit keeps the organization alive` |
| Concurrency: two or more deactivations of the last verticals of an organization at once | Exactly one refusal `409`, the rest `200`, one active vertical left, one record per real change; the lock on the organization's active activations makes the second request wait and see what the first changed | `test/e2e/business-unit-verticals-concurrency.e2e-spec.ts › never leaves the organization without a vertical when every unit turns its only one off at once (trial %i): exactly one refusal, one vertical left`; `test/e2e/business-unit-verticals-concurrency.e2e-spec.ts › holds the same across two business units: the last two verticals, one in each, are never both turned off (trial %i)`; `test/e2e/business-unit-verticals-concurrency.e2e-spec.ts › records one deactivation when the same vertical is turned off at once several times`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-locks.repository.integration-spec.ts › makes a second reader of the active activations wait, then shows it what the first one changed`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-locks.repository.integration-spec.ts › lists only the active activations of active business units of the tenant organization` |
| Malformed vertical code (not `^[a-z][a-z0-9_]{1,79}$`) or a business unit identifier that is not a UUID | `400 · foundation.validation_failed`; nothing changes and no record exists; the identifier is read in lowercase | `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › answers 400 for a business unit identifier that is not a UUID`; `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `src/modules/organization/presentation/dtos/business-unit-vertical-param.schema.spec.ts › refuses %s`; `src/modules/organization/presentation/dtos/business-unit-vertical-param.schema.spec.ts › reads an uppercase UUID as its lowercase canonical form` |
| No session, no or foreign `X-Organization-Id`, an organization that does not exist | `401 · identity.session_required` without a session; otherwise `403 · tenant.context_rejected`; nothing changes and no record exists | `test/e2e/business-unit-verticals-permission.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/business-unit-verticals-permission.e2e-spec.ts › answers 403 tenant.context_rejected for %s, writing and recording nothing` |
| Activating and deactivating leave the invitations of the organization untouched | An open invitation row is byte-for-byte the same after an activation and a deactivation; refusing it at acceptance belongs to O32 | `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › leaves the invitations of the organization exactly as they were, whatever it activates or deactivates` |
| Deactivation leaves the role assignments of that vertical's roles untouched | Assignments kept as labels | `test/e2e/business-unit-verticals-deactivate.e2e-spec.ts` › `leaves the role assignments of the vertical untouched when the vertical is deactivated` |
| Refusals never reveal SQL, tables, constraints or the permission code | Safe generic detail in every refusal | `test/e2e/business-unit-verticals-permission.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal`; `test/e2e/business-unit-verticals-activate-refusals.e2e-spec.ts › refuses a %s vertical: 422 organization.vertical_unavailable, nothing stored, nothing recorded` |
| A database failure; a write aimed at another organization | Never disguised as a vertical failure unless it is one; a write into another organization stores nothing, and Platform names the organization in context explicitly, so even a session RLS does not restrict never reads, changes or locks another organization's activation; the use cases neither catch nor record around the write | `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-locks.repository.integration-spec.ts › reads, changes and locks only the activations of the organization in context, even for a session RLS does not restrict`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › never stores an activation under another organization: the foreign business unit is invisible to the tenant`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-changes.repository.integration-spec.ts › does not disguise a second activation of the same pair as an unavailable vertical` |
| Audit vocabulary and the runtime grants | The two tenant action types exist with the expected flags; the runtime role records both with before and after attributes, inserts, switches off and back on an activation inside its tenant, never deletes, cannot change the vertical of an activation, never sees or touches another organization's activation, and is refused a deprecated vertical or an inactive business unit by the active-context trigger | `database/tests/prepare/organization-business-unit-vertical-contracts.ts › theVocabularyKnowsTheTwoActions`; `database/tests/prepare/organization-business-unit-vertical-contracts.ts › runtimeRecordsBothActionsWithBeforeAndAfter`; `database/tests/prepare/organization-business-unit-vertical-contracts.ts › runtimeTurnsAnActivationOnAndOff`; `database/tests/prepare/organization-business-unit-vertical-contracts.ts › runtimeIsRefusedWhereTheRulesSayNo` |

## Brand

### O12 — Register brand

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: a member with org-wide `organization.account.manage` registers a brand | `201 Created` with `{ brandId, code, name, status }`; the row is `active` under the tenant organization; one `organization.brand.registered` record carries the code and the name as after-only entries | `test/e2e/brands-register.e2e-spec.ts › answers 201 with the brand, stores it active under the organization and records one success record`; `src/modules/organization/application/brands/register-brand.use-case.spec.ts › stores an active brand under the tenant organization and answers it`; `src/modules/organization/application/brands/register-brand.use-case.spec.ts › records exactly one success record whose after values are the created ones, code first`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › stores the brand under the tenant organization with every value it was given` |
| `code` is a non-blank text of at most 50 characters | Accepted as given after trimming, with any visible character; blank, longer than 50 characters, containing NUL or not a string is refused `400 foundation.validation_failed` and nothing is stored or recorded | `test/e2e/brands-register.e2e-spec.ts › accepts a code holding %s and stores it as given`; `test/e2e/brands-register.e2e-spec.ts › accepts a code of 50 characters and a name of 200 characters`; `test/e2e/brands-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › accepts a code made of %s`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › refuses a malformed code: %s` |
| Duplicate `code` within the same organization: exact, in another case, in mixed case or with surrounding whitespace | `409 organization.structure_code_taken`; one row and one record remain; the unique index `(organization_id, lower(code))` is the arbiter and the code is trimmed before it | `test/e2e/brands-register-refusals.e2e-spec.ts › answers 409 organization.structure_code_taken for a code repeated %s, keeping one row and one record`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › refuses a code that differs only in case inside the same organization, with its own failure and one row`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › refuses the very same code inside the same organization` |
| The same `code` in another organization | Allowed, in any case; uniqueness is per organization | `test/e2e/brands-register-refusals.e2e-spec.ts › allows the same code in another organization, even in another case`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › allows the same code in another organization` |
| The caller smuggles `organizationId`, `status`, `id`, `brandId`, `logoUrl` or any unknown field | `400` naming the field as unrecognised; the organization always comes from the tenant context and the status is always `active` | `test/e2e/brands-register-input.e2e-spec.ts › refuses %s smuggled beside a valid body with 400 naming it, storing nothing`; `test/e2e/brands-register-input.e2e-spec.ts › never lets the caller place a brand in another organization through the body`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › refuses %s smuggled beside valid fields`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › names only the unknown key when a valid body carries one` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | Refused by the permission check, not by RLS: `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the organization, committed with the refusal; nothing stored | `test/e2e/brands-permission.e2e-spec.ts › refuses a member with no role: 403 organization.action_forbidden, one denied record against the organization, nothing stored`; `test/e2e/brands-permission.e2e-spec.ts › refuses a member whose only role is scoped to a business unit, on both routes`; `test/e2e/brands-permission.e2e-spec.ts › refuses a member whose role carrying the permission was retired, on both routes`; `src/modules/organization/application/brands/register-brand.use-case.spec.ts › records a denied record against the organization, commits it and only then refuses, storing nothing`; `src/modules/organization/application/brands/register-brand.use-case.spec.ts › checks the permission before it writes, and commits the audit record after the write` |
| A member with an organization-wide custom role that carries the permission | `201`; the record names that member and membership: the permission decides, not the role name | `test/e2e/brands-permission.e2e-spec.ts › lets a member with an organization-wide custom role that carries the permission register and rename, as the owner does` |
| The same forbidden member tries repeatedly | Every attempt leaves its own `denied` record, and no record ever carries the values it tried to write | `test/e2e/brands-permission.e2e-spec.ts › records a denied attempt each time, and never the values it tried to write` |
| Body is empty, lacks a required field, holds an unknown field, a NUL character, a blank or over-long value, or the wrong type | `400 foundation.validation_failed`; nothing stored and no record exists; code is at most 50 characters and the name at most 200 | `test/e2e/brands-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › refuses a body without %s`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › refuses a malformed name: %s`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › refuses %j as a whole body` |
| Hostile text (unicode, RTL, emoji, quotes, semicolons, `%`, backslash) in the name, surrounding whitespace, and names of 200 characters | Stored, returned and audited verbatim; code and name are trimmed first | `test/e2e/brands-register.e2e-spec.ts › keeps hostile text in the name verbatim in the answer, the row and the record`; `test/e2e/brands-register.e2e-spec.ts › trims the code and the name before storing them`; `src/modules/organization/presentation/dtos/register-brand-request.schema.spec.ts › trims the code and the name, and accepts exactly the limits` |
| Several registrations with different codes in one organization | Each answers `201` and leaves its own row and its own record; the identifier is new every time | `test/e2e/brands-register.e2e-spec.ts › registers several brands with different codes in one organization, one record each`; `src/modules/organization/application/brands/register-brand.use-case.spec.ts › gives every registration an identifier of its own` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing stored and no record exists | `test/e2e/brands-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/brands-tenant-context.e2e-spec.ts › refuses a request without X-Organization-Id as a rejected tenant context on both routes`; `test/e2e/brands-tenant-context.e2e-spec.ts › refuses an X-Organization-Id that is %s as a rejected tenant context, storing and recording nothing`; `test/e2e/brands-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/brands-tenant-context.e2e-spec.ts › refuses a member whose membership became %s after the token was issued, on both routes`; `test/e2e/brands-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel registrations of one code | Exactly one `201`, the rest `409`; one row and one record; holds for variants in case and whitespace; parallel registrations of different codes all succeed | `test/e2e/brands-concurrency.e2e-spec.ts › lets exactly one of several parallel registrations of the same code win: one 201, the rest 409, one row, one record`; `test/e2e/brands-concurrency.e2e-spec.ts › holds the same for parallel registrations that differ only in case and whitespace`; `test/e2e/brands-concurrency.e2e-spec.ts › registers parallel brands with different codes all at once, one record each` |
| Refusals never reveal SQL, tables, constraints or the permission code | Safe generic detail in every refusal | `test/e2e/brands-register-refusals.e2e-spec.ts › never reveals SQL, tables or constraints in a conflict`; `test/e2e/brands-permission.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal` |
| A database failure that is not the code index; a write aimed at another organization | Never disguised as a structure failure; it surfaces as itself, nothing is stored, and a write into another organization is refused | `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › does not disguise a database failure caused by %s as a taken code`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › never stores a brand in another organization, and does not disguise that failure`; `src/modules/organization/application/brands/register-brand.use-case.spec.ts › lets %s from the repository propagate untouched, rolled back and unaudited` |
| Audit vocabulary and the runtime grants | The two tenant action types and the resource kind exist with the expected flags; the runtime role inserts, updates and reads only inside its tenant, never deletes, cannot move a row to another organization, a code differing only in case is refused, and a brand is inserted by the runtime role with no vertical activation (registering never depends on O11) | `database/tests/prepare/organization-business-unit-brand-contracts.ts › theVocabularyKnowsTheFourActionsAndTheTwoResourceKinds`; `database/tests/prepare/organization-business-unit-brand-contracts.ts › runtimeRecordsAndReadsBothActions`; `database/tests/prepare/organization-business-unit-brand-contracts.ts › runtimeKeepsRowsInsideItsTenant` |

### O13 — Update brand profile

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: rename | `200 OK` with the brand; one `organization.brand.updated` record carries the old name as before and the new one as after | `test/e2e/brands-update.e2e-spec.ts › renames the brand, answers it and records exactly the name change`; `src/modules/organization/application/brands/update-brand.use-case.spec.ts › writes the new name, answers the renamed brand and records the name change with its old value as before`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › changes the name and never the code, the status or the organization` |
| Only the row named is changed | The other brands of the organization stay as registered and no record names them | `test/e2e/brands-update.e2e-spec.ts › changes only the brand it names and leaves the others of the organization as registered`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › changes only the row it names and leaves the other brands of the organization alone` |
| Attempt to change `code`, `status`, `organizationId` or any unknown field | Refused at the API layer: `400 foundation.validation_failed` naming the field as unrecognised; nothing changes and no record is written | `test/e2e/brands-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/brands-update-input.e2e-spec.ts › names %s as the unrecognised field, so the client learns it cannot be edited`; `test/e2e/brands-update-input.e2e-spec.ts › leaves the code and the status as registered when a mixed body is refused`; `src/modules/organization/presentation/dtos/update-brand-request.schema.spec.ts › rejects %s` |
| Brand belongs to another organization, or does not exist | `404 organization.structure_not_found`, identical for both apart from the echoed path and trace; RLS makes the row invisible, never `403`; nothing changes and no record exists | `test/e2e/brands-update.e2e-spec.ts › answers 404 organization.structure_not_found for an identifier nobody registered, recording nothing`; `test/e2e/brands-update.e2e-spec.ts › answers 404 for the brand of another organization exactly as for one that does not exist, changing nothing`; `test/e2e/brands-update.e2e-spec.ts › answers 200 to the organization that owns the brand while another organization still gets 404 for its identifier`; `src/modules/organization/application/brands/update-brand.use-case.spec.ts › answers not found, writing and auditing nothing, when the brand is not visible`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › cannot see the brand of another organization`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › leaves the brand of another organization untouched, even when its id is named` |
| The path identifier is not a UUID, or is an uppercase UUID | `400` naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercase | `test/e2e/brands-update-input.e2e-spec.ts › answers 400 for a path identifier that is %s, before any lookup`; `test/e2e/brands-update.e2e-spec.ts › accepts the identifier in uppercase, acts on the same row and records the canonical identifier`; `src/modules/organization/presentation/dtos/brand-id-param.schema.spec.ts › reads an uppercase UUID as its lowercase canonical form` |
| The same name sent again | `200 OK` with the current brand; nothing is written, `updated_at` does not move and no record exists; a repeated change records once | `test/e2e/brands-update.e2e-spec.ts › answers 200 with the current brand and records nothing when sent the name already stored, twice`; `test/e2e/brands-update.e2e-spec.ts › moves updated_at only when the name really changed`; `test/e2e/brands-update.e2e-spec.ts › records a rename once: repeating it answers 200 and adds no second record`; `src/modules/organization/application/brands/update-brand.use-case.spec.ts › answers the stored brand, writing and auditing nothing, for %s` |
| A name that differs only in case | A real change: written, answered and audited | `test/e2e/brands-update.e2e-spec.ts › treats a name that differs only in case as a real change`; `src/modules/organization/application/brands/update-brand.use-case.spec.ts › treats a name that differs only in case as a real change` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the brand, committed with the refusal; the permission is checked before the row is locked, so a member without it gets the same answer whether or not the brand exists | `test/e2e/brands-permission.e2e-spec.ts › refuses a member with no role on rename: one denied record against the brand, nothing changed`; `test/e2e/brands-permission.e2e-spec.ts › answers a member without the permission the same way whether the brand exists or not`; `src/modules/organization/application/brands/update-brand.use-case.spec.ts › records a denied record against the brand, commits it and only then refuses, without locking`; `src/modules/organization/application/brands/update-brand.use-case.spec.ts › checks the permission before it locks the row, and locks the row before it updates it` |
| Body is empty, holds a NUL character, a blank or over-long name, or the wrong type | `400 foundation.validation_failed`; nothing changes and no record exists; the name is at most 200 characters | `test/e2e/brands-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `src/modules/organization/presentation/dtos/update-brand-request.schema.spec.ts › rejects %s`; `src/modules/organization/presentation/dtos/update-brand-request.schema.spec.ts › rejects %j as a whole body`; `src/modules/organization/presentation/dtos/update-brand-request.schema.spec.ts › asks for the name, and only that, when the body is empty` |
| Hostile text in the name and surrounding whitespace | Trimmed, stored and returned verbatim | `test/e2e/brands-update.e2e-spec.ts › trims the name and keeps hostile text verbatim`; `src/modules/organization/presentation/dtos/update-brand-request.schema.spec.ts › keeps hostile text verbatim inside the limit` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing changes and no record exists | `test/e2e/brands-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/brands-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/brands-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel `PATCH` requests on one brand | Serialised by `FOR UPDATE`: all answer `200`, every record's `before` is the `after` of the record before it, identical renames write once | `test/e2e/brands-concurrency.e2e-spec.ts › serialises parallel renames: all answer 200 and every record continues the one before it`; `test/e2e/brands-concurrency.e2e-spec.ts › lets exactly one of several parallel identical renames write, and the rest answer 200 without a record`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › holds the row lock until the transaction ends, so a second reader waits` |
| A database failure while updating | Never disguised as a structure failure and never audited: the use case neither catches it nor records, because the audit call comes after the write. Not reachable over HTTP, because the request schema caps the name below the column size | `src/modules/organization/application/brands/update-brand.use-case.spec.ts › lets a database failure propagate untouched, rolled back and unaudited`; `src/modules/organization/infrastructure/brands/brand.repository.integration-spec.ts › does not disguise a name longer than its column as a taken code` |

## Site

### O14 — Register site

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path | `201 Created` with the site, active under the tenant organization; one `organization.site.registered` success record carries the code, the country, the name and the timezone as given and only a presence marker for every address and coordinate field that was set | `test/e2e/sites-register.e2e-spec.ts › answers 201 with the site, stores it active under the organization and records one success record`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › stores an active site under the tenant organization and answers it`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › records exactly one success record, in a fixed order, with address and coordinates only marked as set`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › stores the site under the tenant organization with every value it was given` |
| Address and coordinate values in the audit record | Never written: the record says that an address field or a coordinate was set, not what it holds | `test/e2e/sites-register.e2e-spec.ts › never writes an address or a coordinate value into the audit record`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › never writes an address or a coordinate value into the audit record` |
| Only the four required fields (code, name, country, timezone), or every optional sent as null | `201`; the optionals are stored and answered as null and left out of the record | `test/e2e/sites-register.e2e-spec.ts › registers with only the four required fields: stores nulls, answers nulls and leaves the optionals out of the record`; `test/e2e/sites-register.e2e-spec.ts › registers with every optional null exactly as without them`; `test/e2e/sites-register.e2e-spec.ts › registers a site with an address and no coordinates`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › stores missing optionals as null and leaves them out of the record`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › marks only the optional fields that were given`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › stores a site without address and coordinates as nulls` |
| Invalid IANA timezone string that is well formed (`Mars/Olympus`, `Foo`, `Etc/GMT+99`) | Refused by `sites_validate_timezone_trg` (SQLSTATE `22023`, constraint name `sites_timezone_known`, set by migration `0042`), which only the repository maps, and only when it carries that constraint name: `422 organization.structure_refused` with the detail "That timezone is not recognised."; nothing is stored or audited and the code is not spent | `test/e2e/sites-register-refusals.e2e-spec.ts › answers 422 organization.structure_refused for the well formed but unknown timezone %j, storing and recording nothing`; `test/e2e/sites-register-refusals.e2e-spec.ts › does not spend the code on a refused registration: the same code registers once the timezone is fixed`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › refuses an unknown timezone with its own reason and stores nothing`; `src/modules/organization/infrastructure/sites/site.repository.spec.ts › reports the timezone trigger failure as an invalid timezone`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › lets %s from the repository propagate untouched, rolled back and unaudited`; `database/tests/prepare/organization-site-contracts.ts › theTimezoneTriggerAndTheCoordinateChecksHoldForTheRuntime` |
| A real timezone | Accepted, stored and audited by name (`UTC`, `Europe/Madrid`, `Etc/GMT+5`, `America/Argentina/Buenos_Aires`) | `test/e2e/sites-register.e2e-spec.ts › accepts the real timezone %s, answers it and stores it`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › accepts the real timezone %s` |
| Timezone with a shape no zone has (a space, a double slash, a quote, a NUL, over 100 characters, empty) | Refused at the API layer: `400 foundation.validation_failed`; nothing is stored | `test/e2e/sites-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses %s`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › accepts the timezone shape %s`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › trims the timezone and accepts exactly the limit` |
| Latitude without longitude (or vice versa), or one of them null and the other not | Refused at the API layer: `400 foundation.validation_failed`; the database check `sites_coordinates_chk` stays as the second guard and its failure is never disguised as a structure failure | `test/e2e/sites-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses %s`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › does not disguise a database failure caused by %s as a structure failure`; `database/tests/prepare/organization-site-contracts.ts › theTimezoneTriggerAndTheCoordinateChecksHoldForTheRuntime` |
| Latitude/longitude out of range (past 90 or 180), with more than six decimals, or not a number | Refused at the API layer: `400 foundation.validation_failed`; the exact limits (`90`, `-90`, `180`, `-180`) and six decimals are accepted; the database range checks stay as the second guard | `test/e2e/sites-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › accepts the coordinates %j and %j`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses %s`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › keeps the coordinates exactly as sent`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › does not disguise a database failure caused by %s as a structure failure`; `database/tests/prepare/organization-site-contracts.ts › theTimezoneTriggerAndTheCoordinateChecksHoldForTheRuntime` |
| Coordinates travel as numbers, with no float arithmetic | The answer holds exactly the numbers that were sent (`numeric(9,6)` comes back from PostgreSQL as text and is parsed, never computed on); the stored text has six decimals | `test/e2e/sites-register.e2e-spec.ts › answers exactly the coordinates %j and %j that were sent, as numbers, and stores them`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › gives back exactly the coordinates %j and %j it was given, as numbers` |
| Country in any case, unknown country | The country is trimmed, read in any case as two ASCII letters (the shared `CountryCodeSchema`) and stored in uppercase; digits, dashes and non-ASCII letters are `400`; an unrecognised one is `422 organization.structure_refused` with the detail "That country is not recognised."; nothing is stored or recorded | `test/e2e/sites-register.e2e-spec.ts › reads the country %j and stores and answers %j`; `test/e2e/sites-register-refusals.e2e-spec.ts › answers 422 organization.structure_refused for the country %j, storing and recording nothing`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › reads the country %j as %j`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses a country whose uppercase form is not two characters`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › refuses an unknown country with its own reason and stores nothing` |
| Duplicate `code` within the same organization: exact, in another case, in mixed case or with surrounding whitespace | `409 organization.structure_code_taken`; one row and one record remain; the unique index `(organization_id, lower(code))` is the arbiter and the code is trimmed before it | `test/e2e/sites-register-refusals.e2e-spec.ts › answers 409 organization.structure_code_taken for a code repeated %s, keeping one row and one record`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › refuses a code that differs only in case inside the same organization, with its own failure and one row` |
| The same `code` in another organization | Allowed, in any case; uniqueness is per organization | `test/e2e/sites-register-refusals.e2e-spec.ts › allows the same code in another organization, even in another case`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › allows the same code in another organization` |
| The caller smuggles `organizationId`, `status`, `id`, `siteId` or any unknown field | `400` naming the field as unrecognised; the organization always comes from the tenant context and the status is always `active` | `test/e2e/sites-register-input.e2e-spec.ts › refuses %s smuggled beside a valid body with 400 naming it, storing nothing`; `test/e2e/sites-register-input.e2e-spec.ts › never lets the caller place a site in another organization through the body`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses %s smuggled beside valid fields` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | Refused by the permission check, not by RLS: `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the organization, committed with the refusal; nothing stored | `test/e2e/sites-permission.e2e-spec.ts › refuses a member with no role: 403 organization.action_forbidden, one denied record against the organization, nothing stored`; `test/e2e/sites-permission.e2e-spec.ts › refuses a member whose only role is scoped to a business unit, on both routes`; `test/e2e/sites-permission.e2e-spec.ts › refuses a member whose role carrying the permission was retired, on both routes`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › records a denied record against the organization, commits it and only then refuses, storing nothing` |
| A member with an organization-wide custom role that carries the permission | `201`; the record names that member and membership: the permission decides, not the role name | `test/e2e/sites-permission.e2e-spec.ts › lets a member with an organization-wide custom role that carries the permission register and update, as the owner does` |
| The same forbidden member tries repeatedly | Every attempt leaves its own `denied` record, and no record ever carries the values it tried to write | `test/e2e/sites-permission.e2e-spec.ts › records a denied attempt each time, and never the values it tried to write` |
| Body is empty, lacks a required field, holds a NUL character, a blank or over-long value, or the wrong type | `400 foundation.validation_failed`; nothing stored and no record exists; the code is at most 50 characters, the name 200, an address line 250, a locality or an administrative area 150, a postal code 30 and a timezone 100 | `test/e2e/sites-register-input.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing`; `test/e2e/sites-register-input.e2e-spec.ts › answers 400 for text that is not JSON, storing nothing`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses a body without %s`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses %s`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › refuses %j as a whole body`; `test/e2e/sites-register.e2e-spec.ts › accepts every text field at exactly its column width`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › accepts every text field at exactly its limit` |
| Hostile text (unicode, RTL, emoji, quotes, semicolons, `%`, backslash) and surrounding whitespace | Trimmed, stored, returned and (for the name) audited verbatim | `test/e2e/sites-register.e2e-spec.ts › keeps hostile text verbatim in the answer, the row and the record`; `test/e2e/sites-register.e2e-spec.ts › trims the code, the name, the timezone and every address field before storing them`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › keeps hostile text verbatim inside the limit`; `src/modules/organization/presentation/dtos/register-site-request.schema.spec.ts › trims the code, the name and every address field` |
| Several registrations with different codes in one organization | Each answers `201` and leaves its own row and its own record; the identifier is new every time | `test/e2e/sites-register.e2e-spec.ts › registers several sites with different codes in one organization, one record each`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › gives every registration an identifier of its own` |
| The permission is checked before the write, and the audit is committed with it | Permission, then insert, then the success record, then commit; a repository failure rolls back everything and writes no record | `src/modules/organization/application/sites/register-site.use-case.spec.ts › checks the permission before it writes, and commits the audit record after the write`; `src/modules/organization/application/sites/register-site.use-case.spec.ts › lets %s from the repository propagate untouched, rolled back and unaudited` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing stored and no record exists | `test/e2e/sites-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/sites-tenant-context.e2e-spec.ts › refuses a request without X-Organization-Id as a rejected tenant context on both routes`; `test/e2e/sites-tenant-context.e2e-spec.ts › refuses an X-Organization-Id that is %s as a rejected tenant context, storing and recording nothing`; `test/e2e/sites-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/sites-tenant-context.e2e-spec.ts › refuses a member whose membership became %s after the token was issued, on both routes`; `test/e2e/sites-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel registrations of one code | Exactly one `201`, the rest `409`; one row and one record; holds for variants in case and whitespace; parallel registrations of different codes all succeed | `test/e2e/sites-concurrency.e2e-spec.ts › lets exactly one of several parallel registrations of the same code win: one 201, the rest 409, one row, one record`; `test/e2e/sites-concurrency.e2e-spec.ts › holds the same for parallel registrations that differ only in case and whitespace`; `test/e2e/sites-concurrency.e2e-spec.ts › registers parallel sites with different codes all at once, one record each` |
| Response and refusals never reveal SQL, tables, constraints or the timezone catalog | The conflict, the country refusal and the timezone refusal carry only their public code and detail | `test/e2e/sites-register-refusals.e2e-spec.ts › never reveals SQL, tables or constraints in a conflict or a refusal`; `test/e2e/sites-permission.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal` |
| Audit vocabulary and the runtime grants | The two tenant action types and the `organization.site` resource kind exist with the expected flags; the runtime role records and reads both actions, inserts and updates only inside its tenant, never deletes, cannot move a row to another organization, a code differing only in case is refused, an unknown country is refused, and the timezone trigger and the coordinate checks hold through the runtime role on insert and on update | `database/tests/prepare/organization-site-contracts.ts › theVocabularyKnowsTheTwoActionsAndTheResourceKind`; `database/tests/prepare/organization-site-contracts.ts › runtimeRecordsAndReadsBothActions`; `database/tests/prepare/organization-site-contracts.ts › runtimeKeepsSitesInsideItsTenant`; `database/tests/prepare/organization-site-contracts.ts › theTimezoneTriggerAndTheCoordinateChecksHoldForTheRuntime` |
| A database failure while registering that is not a taken code, an unknown country or an unknown timezone (including any other `22023` that does not carry the constraint `sites_timezone_known`) | Never disguised as a structure failure and never audited: the repository rethrows it and the audit call comes after the write. Reachable only below the API (a code or a name longer than its column, a blank code, a lone coordinate), because the request schema refuses all of them first | `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › does not disguise a database failure caused by %s as a structure failure`; `src/modules/organization/infrastructure/sites/site.repository.spec.ts › does not report an invalid parameter value that names $what as an invalid timezone`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › never stores a site in another organization, and does not disguise that failure` |

### O15 — Update site profile

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: rename | `200 OK` with the site; one `organization.site.updated` record carries the old name as before and the new one as after | `test/e2e/sites-update.e2e-spec.ts › changes the name alone, answers the site and records exactly that change`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › records exactly the fields that really changed, in a fixed order, and answers the new site`; `src/modules/organization/infrastructure/sites/site-update.repository.integration-spec.ts › changes every profile field and never the code, the country, the status or the organization` |
| Only the row named is changed | The other sites of the organization stay as registered and no record names them | `test/e2e/sites-update.e2e-spec.ts › changes only the site it names and leaves the others of the organization as registered`; `src/modules/organization/infrastructure/sites/site-update.repository.integration-spec.ts › changes only the row it names and leaves the other sites of the organization alone` |
| Correct the timezone | `200`; a real timezone is stored and audited by name | `test/e2e/sites-update.e2e-spec.ts › changes the timezone to another real one and audits its names` |
| Correct the address | `200`; the five address fields change; the record carries `set` as before and after for each, never a value | `test/e2e/sites-update-location.e2e-spec.ts › changes the address fields and audits only that they were set, never their values`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › keeps every other field when only %s is sent, and audits it as marked, never as its value` |
| Clear the address with null | Each address field is stored and answered as null; the record carries `set` as before and nothing as after; an empty string is not how a field is cleared | `test/e2e/sites-update-location.e2e-spec.ts › clears every address field with null: stores null, answers null and audits the old value as set and the new as nothing`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › clears the address fields and coordinates when null is sent, with null as after`; `src/modules/organization/infrastructure/sites/site-update.repository.integration-spec.ts › clears the address fields and both coordinates when given null`; `test/e2e/sites-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing` |
| Correct the coordinates, or clear them | Latitude and longitude travel together: both numbers, answered exactly as sent, or both null; the record marks them as set and never carries the numbers; a first pair has nothing as before | `test/e2e/sites-update-location.e2e-spec.ts › changes both coordinates together and answers the numbers that were sent`; `test/e2e/sites-update-location.e2e-spec.ts › clears both coordinates with null and leaves everything else`; `test/e2e/sites-update-location.e2e-spec.ts › sets coordinates on a site registered without them, with nothing as before`; `test/e2e/sites-update-location.e2e-spec.ts › audits only the latitude when the longitude is sent equal to the stored one`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › changes both coordinates together, audits both as marked and never writes their values`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › records a first value with null as before` |
| Same timezone/coordinate guards fire on update: a lone latitude or longitude, a null beside a number, a value out of range, more than six decimals, a timezone with no valid shape | Refused at the API layer: `400 foundation.validation_failed`; nothing changes and no record is written; the database checks stay as the second guard | `test/e2e/sites-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `src/modules/organization/presentation/dtos/update-site-request.schema.spec.ts › rejects %s`; `src/modules/organization/presentation/dtos/update-site-request.schema.spec.ts › asks for the coordinate pair, and only that, when one is sent alone`; `src/modules/organization/infrastructure/sites/site-update.repository.integration-spec.ts › does not disguise a database failure caused by %s on update as a structure failure`; `database/tests/prepare/organization-site-contracts.ts › theTimezoneTriggerAndTheCoordinateChecksHoldForTheRuntime` |
| A well formed timezone that is not a real zone | The trigger (constraint name `sites_timezone_known`) fires on update: `422 organization.structure_refused` with the detail "That timezone is not recognised."; nothing changes, `updated_at` does not move and no record exists | `test/e2e/sites-update.e2e-spec.ts › answers 422 organization.structure_refused for the unknown timezone %j, changing nothing and recording nothing`; `src/modules/organization/infrastructure/sites/site-update.repository.integration-spec.ts › refuses an unknown timezone on update with its own reason and keeps the stored one`; `src/modules/organization/infrastructure/sites/site.repository.spec.ts › reports the timezone trigger failure as an invalid timezone`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › lets %s propagate untouched, rolled back and unaudited` |
| Attempt to change `code`, `countryCode`, `status`, `organizationId` or any unknown field | Refused at the API layer: `400 foundation.validation_failed` naming the field as unrecognised; nothing changes and no record is written | `test/e2e/sites-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/sites-update-input.e2e-spec.ts › names %s as the unrecognised field, so the client learns it cannot be edited`; `test/e2e/sites-update-input.e2e-spec.ts › leaves the immutable fields as registered when a mixed body is refused`; `src/modules/organization/presentation/dtos/update-site-request.schema.spec.ts › rejects %s` |
| Site belongs to another organization, or does not exist | `404 organization.structure_not_found`, identical for both apart from the echoed path and trace; RLS makes the row invisible, never `403`; nothing changes and no record exists | `test/e2e/sites-update.e2e-spec.ts › answers 404 organization.structure_not_found for an identifier nobody registered, recording nothing`; `test/e2e/sites-update.e2e-spec.ts › answers 404 for the site of another organization exactly as for one that does not exist, changing nothing`; `test/e2e/sites-update.e2e-spec.ts › answers 200 to the organization that owns the site while another organization still gets 404 for its identifier`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › answers not found, writing and auditing nothing, when the site is not visible`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › cannot see the site of another organization`; `src/modules/organization/infrastructure/sites/site-update.repository.integration-spec.ts › leaves the site of another organization untouched, even when its id is named` |
| The path identifier is not a UUID, or is an uppercase UUID | `400` naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercase | `test/e2e/sites-update-input.e2e-spec.ts › answers 400 for a path identifier that is %s, before any lookup`; `test/e2e/sites-update.e2e-spec.ts › accepts the identifier in uppercase, acts on the same row and records the canonical identifier`; `src/modules/organization/presentation/dtos/site-id-param.schema.spec.ts › reads an uppercase UUID as its lowercase canonical form` |
| The same values sent again, or null for a field that is already null | `200 OK` with the current site; nothing is written, `updated_at` does not move and no record exists; a repeated change records once; a field sent unchanged beside one that changed is never audited | `test/e2e/sites-update.e2e-spec.ts › answers 200 with the current site and records nothing when sent the values already stored, twice`; `test/e2e/sites-update-location.e2e-spec.ts › answers 200 and records nothing when null is sent for fields that are already null`; `test/e2e/sites-update.e2e-spec.ts › moves updated_at only when something really changed`; `test/e2e/sites-update.e2e-spec.ts › records a change once: repeating it answers 200 and adds no second record`; `test/e2e/sites-update.e2e-spec.ts › never audits a field that was sent unchanged beside one that changed`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › answers the stored site, writing and auditing nothing, for %s`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › never writes or audits a field whose new value equals the stored one`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › treats null sent for fields that are already null as no change` |
| Caller lacks org-wide scope (no role, a role scoped to one business unit, or a retired role) | `403 organization.action_forbidden`, one `denied` record with reason `permission_missing` against the site, committed with the refusal; the permission is checked before the row is locked, so a member without it gets the same answer whether or not the site exists | `test/e2e/sites-permission.e2e-spec.ts › refuses a member with no role on update: one denied record against the site, nothing changed`; `test/e2e/sites-permission.e2e-spec.ts › answers a member without the permission the same way whether the site exists or not`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › records a denied record against the site, commits it and only then refuses, without locking`; `src/modules/organization/application/sites/update-site.use-case.spec.ts › checks the permission before it locks the row, and locks the row before it updates it` |
| Body is empty, holds a NUL character, a blank or over-long value, or the wrong type | `400 foundation.validation_failed`; nothing changes and no record exists | `test/e2e/sites-update-input.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/sites-update-input.e2e-spec.ts › answers 400 for a body that is an array, changing nothing`; `src/modules/organization/presentation/dtos/update-site-request.schema.spec.ts › rejects %j as a whole body`; `src/modules/organization/presentation/dtos/update-site-request.schema.spec.ts › asks for at least one field, and only that, when the body is empty` |
| Hostile text and surrounding whitespace | Trimmed, stored and returned verbatim | `test/e2e/sites-update.e2e-spec.ts › trims the text fields and keeps hostile text verbatim`; `src/modules/organization/presentation/dtos/update-site-request.schema.spec.ts › trims the name, the timezone and the address fields` |
| No session, no or malformed `X-Organization-Id`, an organization the caller is not in, an ended or suspended membership, a suspended or archived organization | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing changes and no record exists | `test/e2e/sites-tenant-context.e2e-spec.ts › answers 401 identity.session_required without a session on both routes, changing nothing`; `test/e2e/sites-tenant-context.e2e-spec.ts › refuses the owner of another organization who names this one, on both routes, and records nothing in either`; `test/e2e/sites-tenant-context.e2e-spec.ts › refuses even the owner once the organization is %s, on both routes` |
| Parallel `PATCH` requests on one site | Serialised by `FOR UPDATE`: all answer `200`, every record's `before` is the `after` of the record before it, identical renames write once, requests on different fields lose no update, and the coordinates of parallel requests are never mixed | `test/e2e/sites-concurrency.e2e-spec.ts › serialises parallel renames: all answer 200 and every record continues the one before it`; `test/e2e/sites-concurrency.e2e-spec.ts › lets exactly one of several parallel identical renames write, and the rest answer 200 without a record`; `test/e2e/sites-concurrency.e2e-spec.ts › loses no update when parallel requests change different fields`; `test/e2e/sites-concurrency.e2e-spec.ts › never mixes the coordinates of two parallel requests: the stored pair is always one that was sent`; `src/modules/organization/infrastructure/sites/site.repository.integration-spec.ts › holds the row lock until the transaction ends, so a second reader waits` |
| A database failure while updating | Never disguised as a structure failure and never audited: the use case neither catches it nor records, because the audit call comes after the write. Reachable only below the API (a name longer than its column, a lone coordinate, a latitude past the pole) | `src/modules/organization/application/sites/update-site.use-case.spec.ts › lets %s propagate untouched, rolled back and unaudited`; `src/modules/organization/infrastructure/sites/site-update.repository.integration-spec.ts › does not disguise a database failure caused by %s on update as a structure failure` |

## Outlet

### O16 — Open outlet

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: outlet tied to one brand and one site in the same organization | `201 Created` with the outlet, stored active; one `organization.outlet.opened` record carries the created values as after; a top-level outlet has no parent attribute | `test/e2e/outlets-open.e2e-spec.ts › opens a main outlet under a brand and a site: 201 with the outlet, stored active, one success record with the created values`; `src/modules/organization/application/outlets/open-outlet.use-case.spec.ts › records one success record with the created values as after attributes and no parent attribute for a top-level outlet` |
| Nested outlet under a parent of the same site, two levels deep, or an explicit null parent | `201`; the parent is stored, answered and recorded as an after attribute | `test/e2e/outlets-open.e2e-spec.ts › opens a bar nested under the main outlet: the parent is stored, answered and recorded`; `test/e2e/outlets-open.e2e-spec.ts › nests an outlet two levels deep`; `test/e2e/outlets-open.e2e-spec.ts › accepts an explicit null parent as a top-level outlet` |
| Adversarial: `brand_id` from another organization, or unknown | `422 organization.structure_refused`, the same answer for both, nothing leaked, stored or recorded. As built, the activation trigger (`organization_outlet_activation_brand`) answers before the composite FK, so an unknown, foreign or inactive brand are one and the same refusal | `test/e2e/outlets-open-refusals.e2e-spec.ts › refuses a brand of another organization exactly as it refuses an unknown brand, with nothing stored or leaked`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › refuses a brand of another organization exactly as it refuses an unknown one, storing nothing`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › refuses a brand that is no longer active` |
| Adversarial: `site_id` from another organization, or unknown | `422 organization.structure_refused`, the same answer for both (`organization_outlet_activation_site`) | `test/e2e/outlets-open-refusals.e2e-spec.ts › refuses a site of another organization exactly as it refuses an unknown site`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › refuses a site of another organization exactly as it refuses an unknown one, storing nothing` |
| `parent_outlet_id` on a different `site_id`, of another organization, or unknown | `422 organization.structure_refused`, one and the same answer for the three (`outlets_parent_site_organization_fk`); nothing stored or recorded | `test/e2e/outlets-open-refusals.e2e-spec.ts › refuses a parent outlet of another organization, an unknown parent and a parent on another site with one and the same answer`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › refuses a parent on another site, a parent of another organization and an unknown parent with the same answer` |
| `parent_outlet_id = id` (self-parent) | Not reachable when opening: the identifier is generated by the server, so the outlet cannot name itself. Moving an outlet under itself is refused as a cycle (O18); `outlets_not_self_parent_chk` stays the last backstop at the database | `test/e2e/outlets-reparent.e2e-spec.ts › refuses a cycle, %s, with 422 organization.structure_refused, changing nothing and recording nothing` |
| Active outlet opened under an inactive parent | `422 organization.structure_refused` (`organization_outlet_parent_active`); nothing stored | `test/e2e/outlets-open-refusals.e2e-spec.ts › refuses to open an active outlet under an inactive parent`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › refuses to open an active outlet under an inactive parent` |
| Code already taken in the organization, ignoring case and surrounding spaces | `409 organization.structure_code_taken`; nothing stored and no record; the same code is free in another organization | `test/e2e/outlets-open-refusals.e2e-spec.ts › answers 409 for a code already taken in the organization, ignoring case and spaces, storing and recording nothing more`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › refuses a code that differs only in case inside the same organization, with its own failure and one row`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › allows the same code in another organization` |
| Parallel openings of one code, or of different codes | Exactly one `201` and the rest `409` for the same code; all `201` for different codes; one record each | `test/e2e/outlets-concurrency.e2e-spec.ts › lets exactly one of several parallel openings of the same code win: one 201, the rest 409, one row, one record`; `test/e2e/outlets-concurrency.e2e-spec.ts › opens parallel outlets with different codes all at once, one record each` |
| Body is incomplete, holds a NUL character, a blank or over-long value, an identifier that is not a UUID, an unknown field (status, organization, id) or the wrong type | `400 foundation.validation_failed`; nothing stored and no record | `test/e2e/outlets-open-refusals.e2e-spec.ts › answers 400 for %s, storing nothing and recording nothing` |
| Hostile text, whitespace, limits of 50 and 200 characters, upper-case identifiers | Trimmed, stored and returned verbatim; identifiers are accepted in upper case and answered in lower case | `test/e2e/outlets-open.e2e-spec.ts › keeps hostile text in the name verbatim in the answer, the row and the record`; `test/e2e/outlets-open.e2e-spec.ts › trims the code and the name and keeps the case of the code`; `test/e2e/outlets-open.e2e-spec.ts › accepts a code of 50 characters and a name of 200 characters`; `test/e2e/outlets-open-refusals.e2e-spec.ts › accepts an upper-case identifier for the brand, the site and the parent, as the other structure routes do` |
| Caller lacks organization-wide `organization.account.manage` (no role, a role scoped to a business unit) | `403 organization.action_forbidden`, one `denied` record against the organization with reason `permission_missing`; nothing stored; a member with an organization-wide custom role is allowed | `test/e2e/outlets-access.e2e-spec.ts › refuses a cashier-like member with no role on opening: 403, one denied record against the organization, nothing stored`; `test/e2e/outlets-access.e2e-spec.ts › refuses a member whose only role is scoped to a business unit, on all three routes`; `test/e2e/outlets-access.e2e-spec.ts › lets a member with an organization-wide role that carries the permission open, rename and re-parent as the owner does`; `src/modules/organization/application/outlets/open-outlet.use-case.spec.ts › records a denied record against the organization, commits it and only then refuses, storing nothing` |
| No session, no `X-Organization-Id`, an organization the caller is not in | `401 identity.session_required` without a session; otherwise `403 tenant.context_rejected`; nothing changes | `test/e2e/outlets-access.e2e-spec.ts › answers 401 identity.session_required without a session on all three routes, changing nothing`; `test/e2e/outlets-access.e2e-spec.ts › refuses a request without X-Organization-Id and a header naming an organization the caller does not belong to` |
| A database failure while opening | Never disguised as a taken code or a refusal, never audited | `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › does not disguise a database failure caused by %s as a taken code or a refusal`; `src/modules/organization/application/outlets/open-outlet.use-case.spec.ts › lets %s from the repository propagate untouched, rolled back and unaudited` |
| Refusals never reveal SQL, constraint names or permissions | No SQL, table, constraint or permission text in any refusal | `test/e2e/outlets-access.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal` |
| Two organizations side by side | Each outlet exists only in its own organization | `test/e2e/outlets-open.e2e-spec.ts › opens outlets of two organizations side by side, each only in its own organization` |

### O17 — Update outlet profile

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: rename | `200 OK` with the outlet; code, brand, site, parent and status keep their values; one `organization.outlet.updated` record carries the old name as before and the new one as after | `test/e2e/outlets-update.e2e-spec.ts › answers 200 with the renamed outlet and keeps its code, brand, site, parent and status, with one record holding the old and the new name`; `src/modules/organization/application/outlets/update-outlet.use-case.spec.ts › records the name change with its old value as before`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › changes the name and never the code, the brand, the site, the parent or the status` |
| Attempt to change `brand_id`/`site_id` (or the parent, the code, the status, the organization) through this route | Refused at the API layer: `400 foundation.validation_failed`; nothing changes and no record exists | `test/e2e/outlets-update.e2e-spec.ts › answers 400 for %s at the API layer, changing nothing and recording nothing` |
| The name already stored, or the same rename sent again | `200 OK` with the current outlet; nothing is written and no record exists; parallel identical renames write once | `test/e2e/outlets-update.e2e-spec.ts › answers 200 for the name already stored, writing nothing and recording nothing, even when it only differs by surrounding spaces`; `test/e2e/outlets-update.e2e-spec.ts › replays a rename: the second identical request is a 200 without a second record`; `test/e2e/outlets-update.e2e-spec.ts › lets exactly one of several parallel identical renames write, and the rest answer 200 without a record`; `src/modules/organization/application/outlets/update-outlet.use-case.spec.ts › answers the stored outlet and writes and audits nothing when it is sent %s` |
| Outlet belongs to another organization, or does not exist | `404 organization.structure_not_found`, identical for both; nothing changes and no record exists | `test/e2e/outlets-update.e2e-spec.ts › answers 404 organization.structure_not_found for an unknown outlet and for the outlet of another organization, with the same answer and nothing changed`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › does not see an outlet of another organization or an unknown one`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › changes only the row it names and leaves the outlet of another organization untouched` |
| The path identifier is not a UUID, or is in upper case | `400` before any lookup; an upper-case identifier is accepted | `test/e2e/outlets-update.e2e-spec.ts › answers 400 for an identifier that is not a UUID` |
| Caller lacks the permission | `403 organization.action_forbidden`, one `denied` record against the outlet, committed with the refusal; the permission is checked before the row is locked, so the answer is the same whether or not the outlet exists | `test/e2e/outlets-access.e2e-spec.ts › refuses the same member on rename and on re-parenting: one denied record against the outlet each, nothing changed`; `test/e2e/outlets-access.e2e-spec.ts › answers a member without the permission the same way whether the outlet exists or not`; `src/modules/organization/application/outlets/update-outlet.use-case.spec.ts › records a denied record against the outlet, then refuses, never touching the row` |
| Hostile text and surrounding whitespace | Trimmed, stored and returned verbatim | `test/e2e/outlets-update.e2e-spec.ts › keeps hostile text in the name verbatim`; `test/e2e/outlets-update.e2e-spec.ts › trims the name` |
| Parallel renames of one outlet | Serialised by `FOR UPDATE`: all `200`, every record's `before` is the `after` of the record before it | `test/e2e/outlets-update.e2e-spec.ts › serialises parallel renames: all answer 200 and every record continues the one before it`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › locks the row so that a second FOR UPDATE waits for the first transaction` |
| A database failure while renaming | Never disguised as a taken code and never audited | `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › does not disguise a name longer than its column as a taken code`; `src/modules/organization/application/outlets/update-outlet.use-case.spec.ts › lets a repository failure propagate untouched, rolled back and unaudited` |

### O18 — Re-parent an outlet

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: move an outlet under a new, valid parent on the same site | `200 OK` with the new parent, stored; one `organization.outlet.reparented` record carries the parent as before and after | `test/e2e/outlets-reparent.e2e-spec.ts › moves a top-level outlet under another: 200 with the new parent, stored, one record with before and after`; `test/e2e/outlets-reparent.e2e-spec.ts › moves a nested outlet under another parent and records the old parent as before`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › moves a top-level outlet under a parent and then detaches it again` |
| Detach a nested outlet to the top level of its site | `200` with a null parent; its own children stay under it | `test/e2e/outlets-reparent.e2e-spec.ts › detaches a nested outlet to the top level of its site with a null parent`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › moves a nested outlet under another parent and keeps its own children` |
| The same parent again, or no parent for a top-level outlet | `200` with the current outlet; nothing is written and no record exists | `test/e2e/outlets-reparent.e2e-spec.ts › answers 200 without a write and without a record when the outlet already has that parent, and for a top-level outlet detached again`; `test/e2e/outlets-reparent.e2e-spec.ts › replays a move: the second identical request is a 200 without a second record`; `src/modules/organization/application/outlets/reparent-outlet.use-case.spec.ts › answers the stored outlet and writes and audits nothing for %s` |
| Adversarial: move that would create a cycle (under itself, its child, its grandchild) | `422 organization.structure_refused` (`23514 · organization_outlet_acyclic` mapped in the repository); nothing changes and no record exists; no constraint name in the answer | `test/e2e/outlets-reparent.e2e-spec.ts › refuses a cycle, %s, with 422 organization.structure_refused, changing nothing and recording nothing`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › refuses a cycle: %s, leaving the tree as it was` |
| Adversarial: new parent chain contains an inactive outlet, target stays active | `422 organization.structure_refused` (`23514 · organization_outlet_parent_active`); the ancestor is set inactive with SQL because deactivation (O22) has no route yet | `test/e2e/outlets-reparent.e2e-spec.ts › refuses to move an active outlet under an inactive one, and under an active one whose ancestor is inactive`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › refuses to put an active outlet under an inactive one`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › refuses to put an active outlet under an active one whose own ancestor is inactive` |
| New parent on a different `site_id`, of another organization, or unknown | `422 organization.structure_refused`, one and the same answer for the three; nothing changes | `test/e2e/outlets-reparent.e2e-spec.ts › refuses a parent on another site of the same organization, a parent of another organization and an unknown parent with one and the same answer`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › refuses a parent on another site, a parent of another organization and an unknown parent with the same answer` |
| Concurrency: two simultaneous re-parents on the same organization's outlet tree | Serialised by the per-organization advisory lock: the second waits, then re-evaluates against the first's result; over HTTP exactly one `200` and one `422`, and the tree has no cycle; a ring of moves keeps the tree acyclic | `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › serialises two opposite moves: the second waits for the first, then is refused as a cycle, leaving one parent link`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › serialises moves of different outlets under the same organization tree even when they could not conflict`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › does not make a move in another organization wait`; `test/e2e/outlets-concurrency.e2e-spec.ts › settles two opposite moves at once into one outcome: one 200, one 422, and a tree without a cycle`; `test/e2e/outlets-concurrency.e2e-spec.ts › keeps a ring of outlets acyclic when every outlet is moved under the next at once` |
| Outlet belongs to another organization, or does not exist | `404 organization.structure_not_found`, identical for both; nothing changes | `test/e2e/outlets-reparent.e2e-spec.ts › answers 404 for an unknown outlet and for the outlet of another organization with the same answer, changing nothing`; `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › leaves the outlet of another organization untouched, even when its id is named`; `src/modules/organization/application/outlets/reparent-outlet.use-case.spec.ts › answers not found, writing and auditing nothing, when the outlet is not visible` |
| Body is empty, lacks the parent, names another field, or the identifiers are not UUIDs; upper-case identifiers | `400 foundation.validation_failed`; nothing changes; upper-case identifiers are accepted | `test/e2e/outlets-reparent.e2e-spec.ts › answers 400 for %s, changing nothing and recording nothing`; `test/e2e/outlets-reparent.e2e-spec.ts › answers 400 for an outlet identifier that is not a UUID`; `test/e2e/outlets-reparent.e2e-spec.ts › accepts an upper-case identifier for the outlet and for the parent` |
| Caller lacks the permission | `403 organization.action_forbidden`, one `denied` record against the outlet | `test/e2e/outlets-access.e2e-spec.ts › refuses the same member on rename and on re-parenting: one denied record against the outlet each, nothing changed`; `src/modules/organization/application/outlets/reparent-outlet.use-case.spec.ts › records a denied record against the outlet, then refuses, never touching the row` |
| Only the parent changes | Name, code, brand, site and status keep their values | `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › changes only the parent, leaving name, code, brand, site and status as they were` |
| A refusal from the database while moving | Propagates untouched, rolled back and unaudited | `src/modules/organization/application/outlets/reparent-outlet.use-case.spec.ts › lets a refusal from the repository propagate untouched, rolled back and unaudited` |
| Journey: provision, brand and site, open, nest, rename, re-parent, detach | At every step the owner sees the outlet answered; a vertical role (waiter) can be given organization-wide, is refused at the scope of an outlet with no business unit with `422 identity.role_assignment_vertical_inactive`, and is accepted there with `201` once `POST /outlets/{id}/business-unit-assignments` gave the outlet a business unit that runs the vertical | `test/e2e/outlets-journey.e2e-spec.ts › shows the owner, at every step, the outlet they just opened, nested, renamed, moved and detached, and a waiter can be given the whole restaurant, is refused at an outlet without a business unit and is accepted there once the unit is assigned` |
| Journey: provision with the restaurant vertical, register the legal entity, the brand and the site, open an outlet with its business unit and legal entity in the same request, invite a cashier and a waiter at its scope, move it to another legal entity from a later date | The waiter invitation at the outlet scope answers `201` (it was `422 identity.invitation_role_vertical_inactive` while the outlet had no business unit, and still is for an outlet opened without one until it is assigned); the old legal entity period is closed at the new start and stays `active` | `test/e2e/outlets-assignments-journey.e2e-spec.ts › opens an outlet with its business unit and legal entity in one request, lets a cashier and a waiter be invited at its scope, then moves the outlet to another legal entity from a later date` |
| Concurrency: a move or a deactivation of an outlet racing a sale, cash or platform command that runs `organization.assert_outlet_operational` on it | No deadlock: every outlet write takes the exclusive outlet tree advisory lock before the outlet row, the order the guard uses, so the guard waits and then refuses an inactive outlet or passes a moved one; with the row first and the tree lock later in the trigger, PostgreSQL aborted one side with `40P01` | `database/tests/concurrency/outlet-lock-order-concurrency.ts` › `aDeactivationRacingTheGuardNeverDeadlocks`; `database/tests/concurrency/outlet-lock-order-concurrency.ts` › `aMoveRacingTheGuardNeverDeadlocks`; `src/modules/organization/infrastructure/outlets/outlet.repository.integration-spec.ts › takes the exclusive outlet tree lock of its organization before the outlet row, so the operational guard waits on the tree instead of deadlocking` |

## Sales channel

### O19 — Register sales channel

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path | `201 Created` | — |
| Unknown `channel_kind` | Refused by the check constraint | — |
| Duplicate code | Refused | — |

## Effective assignments

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

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: outlet with no current assignment gets one starting today (the date of its site) | `201 Created`, `status = 'active'`, open period, one `organization.outlet.legal_entity_assigned` record with the target and the date as after values; the date is taken from the timezone of the site | `test/e2e/outlets-assign.e2e-spec.ts › assigns the target from today at the site of the outlet: 201 with an open period, one active row and one record with the target and the date as after values`; `test/e2e/outlets-assign.e2e-spec.ts › takes today from the timezone of the site of the outlet, not from the database date` |
| Happy path: outlet with a current assignment gets a new one starting today or later | The current row is closed at the new start (`valid_until = valid_from` of the new one) and stays `active`, so the resolver still answers for past dates; a row that starts the same day is `superseded` instead; a bounded assignment keeps its end date | `test/e2e/outlets-assign.e2e-spec.ts › replaces an assignment made the same day: the first row is superseded, the second is active and the record carries the first target as before`; `test/e2e/outlets-assign.e2e-spec.ts › closes the current assignment at a future date and keeps it active, so the old period stays readable through the resolver`; `test/e2e/outlets-assign.e2e-spec.ts › closes at today an assignment that started in the past and keeps it active (the past row is seeded with SQL: no route can start in the past)`; `test/e2e/outlets-assign.e2e-spec.ts › stores a bounded assignment with its end date` |
| Repeat of the same target, current from that date | `200` with the same body; nothing written, no record | `test/e2e/outlets-assign.e2e-spec.ts › answers 200 with the same body, writing and recording nothing, when the target is already current from that date` |
| Adversarial: `valid_until <= valid_from` | `400 foundation.validation_failed` when both dates are sent, `422 organization.structure_refused` when the start defaults to today; the period check constraint is the second guard | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses an end that is not after the day it starts: 400 when both dates are sent, 422 when the start defaults to today`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › refuses in the database an end that is not after the start and a target of another organization, whoever writes` |
| Adversarial: start date before today at the site of the outlet | `422 organization.structure_refused`; nothing written, no record | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses a start date in the past with 422 organization.structure_refused, writing and recording nothing` |
| Adversarial: another assignment of the outlet already starts after the date | `409 organization.assignment_conflict`; nothing written, no record | `test/e2e/outlets-assign.e2e-spec.ts › refuses a start date before another assignment that is already waiting: 409 organization.assignment_conflict, nothing written or recorded` |
| Adversarial: legal entity from another organization, or unknown, or outlet from another organization, or unknown | `404 organization.structure_not_found`, one and the same answer for the four; the composite FK is the second guard | `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 404 organization.structure_not_found with one and the same answer for an unknown outlet, an outlet of another organization, an unknown target and a target of another organization`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › reports an unknown or foreign target as missing and an inactive one as not active, and refuses to insert an inactive one as not active and a foreign or unknown one as missing` |
| Adversarial: legal entity or outlet that is not active | `422 organization.structure_refused`, a detail of its own for the target and for the outlet; `organization_outlet_assignment_legal_entity_active` is the second guard | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses a target that is not active and an outlet that is not active with 422 organization.structure_refused, writing nothing`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › reports an unknown or foreign target as missing and an inactive one as not active, and refuses to insert an inactive one as not active and a foreign or unknown one as missing` |
| Concurrency: two simultaneous "assign from today" commands for the same outlet | The organization-wide structure version row lock, taken before the outlet tree lock and the outlet row lock, serializes them: exactly one `201`, the rest `200` (same target) or a same-day replacement (another target), one active row covering the day; with the lock bypassed the GiST exclusion lets exactly one win and the other fails with `23P01` | `test/e2e/outlets-assign-concurrency.e2e-spec.ts › lets exactly one of several parallel assignments of the same target on the same day create the row: one 201, the rest 200, one active row, one record`; `test/e2e/outlets-assign-concurrency.e2e-spec.ts › keeps exactly one active row covering the day when parallel assignments name two targets: every answer is a success and the superseded rows match the records`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › answers a conflict when a second active period overlaps the first, and stores nothing of the second` |
| Attempt to `UPDATE` `outlet_id`/`legal_entity_id`/`valid_from` on an existing row in place | Refused by `protect_outlet_legal_entity_assignment`; closing the period stays allowed | `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › refuses an in-place change of the outlet, the target or the start of an assignment, and lets its period be closed` |
| Caller lacks the permission (cashier, member scoped to a business unit) | `403 organization.action_forbidden`, one `denied` record against the outlet; nothing written | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses a cashier and a member whose only role is scoped to a business unit with 403 organization.action_forbidden, one denied record against the outlet each, nothing written` |
| No session, no organization header, or an organization the caller does not belong to | `401 identity.session_required` or `403 tenant.context_rejected`; nothing written | `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 401 without a session and 403 tenant.context_rejected without the header or with an organization the caller does not belong to` |
| Malformed body, unknown field, malformed date or identifier | `400 foundation.validation_failed`; nothing written | `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 400 for %s, writing nothing`; `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 400 for an identifier that is not a UUID in the path and in the body` |
| Opening an outlet with the optional `legalEntityId` (D4) | The assignment is created from today in the same transaction as the outlet; an unknown or foreign target is `404 organization.structure_not_found`, the same answer as the assign route (an assignment target is looked up as a resource), an inactive one is `422 organization.structure_refused`, and nothing at all is stored | `test/e2e/outlets-open-assignments.e2e-spec.ts › assigns both from today in the same transaction: 201, one active open row each and one opened record carrying both identifiers as after values`; `test/e2e/outlets-open-assignments.e2e-spec.ts › answers an unknown or foreign business unit or legal entity with 404 organization.structure_not_found, the same answer the assign routes give, and stores no outlet from the refused requests and no record`; `test/e2e/outlets-open-assignments.e2e-spec.ts › refuses an inactive business unit or legal entity with 422 organization.structure_refused naming which one, and stores no outlet, no assignment and no record` |
| Repeating the same legal entity with a different `validUntil` (D5): set, extend, shorten or clear the end of the current row | `200` with the same row and its new end (`null` clears it); one record under `organization.outlet.legal_entity_assigned` with `organization.outlet.valid_until` before and after | `test/e2e/outlets-assign-end-date.e2e-spec.ts › %s the end date when the same target is repeated with another validUntil: 200 with the new end, the same row, and one record with the end before and after`; `test/e2e/outlets-assign-end-date.e2e-spec.ts › clears the end date with validUntil null: 200 with an open period and one record with the old end as before`; `src/modules/organization/application/outlets/plan-assignment.spec.ts › moves the end of the same target covering the date when another end is sent` |
| Repeating the same legal entity omitting `validUntil`, or with the end already stored | `200` with the stored end; nothing written or recorded, as before D5 | `test/e2e/outlets-assign-end-date.e2e-spec.ts › leaves the end date unchanged when %s: 200 with the stored end, nothing written or recorded`; `src/modules/organization/application/outlets/plan-assignment.spec.ts › leaves the same target unchanged when the end is omitted or identical` |
| A moved end that reaches past the start of the next assignment, `null` while one waits, an end on or before that start, and an end not after the start of the row | `409 organization.assignment_conflict` for the first two, nothing written or recorded; an end on or before the next start is accepted and leaves a gap; an end not after the start is `422 organization.structure_refused` (`400` when both dates are sent) | `test/e2e/outlets-assign-end-date.e2e-spec.ts › refuses %s: 409 organization.assignment_conflict, nothing written or recorded`; `test/e2e/outlets-assign-end-date.e2e-spec.ts › accepts an end that stops exactly where the next assignment starts, or before it, leaving a gap that is never filled`; `test/e2e/outlets-assign-end-date.e2e-spec.ts › refuses an end that is not after the start of the current assignment: 422 organization.structure_refused when the start is the default, 400 when it is sent`; `src/modules/organization/application/outlets/plan-assignment.spec.ts › refuses an end that reaches past the start of the next row, or an open end before it, and accepts one that stops on that start` |
| Concurrency: two end dates sent at once for the same current row | Both `200`; one row whose end is the after value of the last record, whose before is the after value of the first | `test/e2e/outlets-assign-end-date.e2e-spec.ts › keeps one consistent row when two end dates are sent in parallel: both answer 200 and the stored end is the one the last record wrote` |
| A bounded assignment `[d1, d2)` over a current open one | The current row is closed at `d1`; from `d2` on nothing is assigned: a gap is never filled | `test/e2e/outlets-assign-end-date.e2e-spec.ts › a bounded assignment [d1, d2) closes the current one at d1 and leaves a gap after d2 that is never filled` |

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

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: outlet with no current assignment gets one starting today (the date of its site) | `201 Created`, `status = 'active'`, open period, one `organization.outlet.business_unit_assigned` record with the target and the date as after values; the date is taken from the timezone of the site | `test/e2e/outlets-assign.e2e-spec.ts › assigns the target from today at the site of the outlet: 201 with an open period, one active row and one record with the target and the date as after values`; `test/e2e/outlets-assign.e2e-spec.ts › takes today from the timezone of the site of the outlet, not from the database date` |
| Happy path: outlet with a current assignment gets a new one starting today or later | The current row is closed at the new start (`valid_until = valid_from` of the new one) and stays `active`, so the resolver still answers for past dates; a row that starts the same day is `superseded` instead; a bounded assignment keeps its end date | `test/e2e/outlets-assign.e2e-spec.ts › replaces an assignment made the same day: the first row is superseded, the second is active and the record carries the first target as before`; `test/e2e/outlets-assign.e2e-spec.ts › closes the current assignment at a future date and keeps it active, so the old period stays readable through the resolver`; `test/e2e/outlets-assign.e2e-spec.ts › closes at today an assignment that started in the past and keeps it active (the past row is seeded with SQL: no route can start in the past)`; `test/e2e/outlets-assign.e2e-spec.ts › stores a bounded assignment with its end date` |
| Repeat of the same target, current from that date | `200` with the same body; nothing written, no record | `test/e2e/outlets-assign.e2e-spec.ts › answers 200 with the same body, writing and recording nothing, when the target is already current from that date` |
| Adversarial: `valid_until <= valid_from` | `400 foundation.validation_failed` when both dates are sent, `422 organization.structure_refused` when the start defaults to today; the period check constraint is the second guard | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses an end that is not after the day it starts: 400 when both dates are sent, 422 when the start defaults to today`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › refuses in the database an end that is not after the start and a target of another organization, whoever writes` |
| Adversarial: start date before today at the site of the outlet | `422 organization.structure_refused`; nothing written, no record | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses a start date in the past with 422 organization.structure_refused, writing and recording nothing` |
| Adversarial: another assignment of the outlet already starts after the date | `409 organization.assignment_conflict`; nothing written, no record | `test/e2e/outlets-assign.e2e-spec.ts › refuses a start date before another assignment that is already waiting: 409 organization.assignment_conflict, nothing written or recorded` |
| Adversarial: business unit from another organization, or unknown, or outlet from another organization, or unknown | `404 organization.structure_not_found`, one and the same answer for the four; the composite FK is the second guard | `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 404 organization.structure_not_found with one and the same answer for an unknown outlet, an outlet of another organization, an unknown target and a target of another organization`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › reports an unknown or foreign target as missing and an inactive one as not active, and refuses to insert an inactive one as not active and a foreign or unknown one as missing` |
| Adversarial: business unit or outlet that is not active | `422 organization.structure_refused`, a detail of its own for the target and for the outlet; `organization_outlet_assignment_business_unit_active` is the second guard | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses a target that is not active and an outlet that is not active with 422 organization.structure_refused, writing nothing`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › reports an unknown or foreign target as missing and an inactive one as not active, and refuses to insert an inactive one as not active and a foreign or unknown one as missing` |
| Concurrency: two simultaneous "assign from today" commands for the same outlet | The organization-wide structure version row lock, taken before the outlet tree lock and the outlet row lock, serializes them: exactly one `201`, the rest `200` (same target) or a same-day replacement (another target), one active row covering the day; with the lock bypassed the GiST exclusion lets exactly one win and the other fails with `23P01` | `test/e2e/outlets-assign-concurrency.e2e-spec.ts › lets exactly one of several parallel assignments of the same target on the same day create the row: one 201, the rest 200, one active row, one record`; `test/e2e/outlets-assign-concurrency.e2e-spec.ts › keeps exactly one active row covering the day when parallel assignments name two targets: every answer is a success and the superseded rows match the records`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › answers a conflict when a second active period overlaps the first, and stores nothing of the second` |
| Attempt to `UPDATE` `outlet_id`/`business_unit_id`/`valid_from` on an existing row in place | Refused by `protect_outlet_business_unit_assignment`; closing the period stays allowed | `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › refuses an in-place change of the outlet, the target or the start of an assignment, and lets its period be closed` |
| Caller lacks the permission (cashier, member scoped to a business unit) | `403 organization.action_forbidden`, one `denied` record against the outlet; nothing written | `test/e2e/outlets-assign-refusals.e2e-spec.ts › refuses a cashier and a member whose only role is scoped to a business unit with 403 organization.action_forbidden, one denied record against the outlet each, nothing written` |
| No session, no organization header, or an organization the caller does not belong to | `401 identity.session_required` or `403 tenant.context_rejected`; nothing written | `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 401 without a session and 403 tenant.context_rejected without the header or with an organization the caller does not belong to` |
| Malformed body, unknown field, malformed date or identifier | `400 foundation.validation_failed`; nothing written | `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 400 for %s, writing nothing`; `test/e2e/outlets-assign-refusals.e2e-spec.ts › answers 400 for an identifier that is not a UUID in the path and in the body` |
| Opening an outlet with the optional `businessUnitId` (D4) | The assignment is created from today in the same transaction as the outlet; an unknown or foreign target is `404 organization.structure_not_found`, the same answer as the assign route (an assignment target is looked up as a resource), an inactive one is `422 organization.structure_refused`, and nothing at all is stored | `test/e2e/outlets-open-assignments.e2e-spec.ts › assigns both from today in the same transaction: 201, one active open row each and one opened record carrying both identifiers as after values`; `test/e2e/outlets-open-assignments.e2e-spec.ts › answers an unknown or foreign business unit or legal entity with 404 organization.structure_not_found, the same answer the assign routes give, and stores no outlet from the refused requests and no record`; `test/e2e/outlets-open-assignments.e2e-spec.ts › refuses an inactive business unit or legal entity with 422 organization.structure_refused naming which one, and stores no outlet, no assignment and no record` |
| Repeating the same business unit with a different `validUntil` (D5): set, extend, shorten or clear the end of the current row | `200` with the same row and its new end (`null` clears it); one record under `organization.outlet.business_unit_assigned` with `organization.outlet.valid_until` before and after | `test/e2e/outlets-assign-end-date.e2e-spec.ts › %s the end date when the same target is repeated with another validUntil: 200 with the new end, the same row, and one record with the end before and after`; `test/e2e/outlets-assign-end-date.e2e-spec.ts › clears the end date with validUntil null: 200 with an open period and one record with the old end as before`; `src/modules/organization/application/outlets/plan-assignment.spec.ts › moves the end of the same target covering the date when another end is sent` |
| Repeating the same business unit omitting `validUntil`, or with the end already stored | `200` with the stored end; nothing written or recorded, as before D5 | `test/e2e/outlets-assign-end-date.e2e-spec.ts › leaves the end date unchanged when %s: 200 with the stored end, nothing written or recorded`; `src/modules/organization/application/outlets/plan-assignment.spec.ts › leaves the same target unchanged when the end is omitted or identical` |
| A moved end that reaches past the start of the next assignment, `null` while one waits, an end on or before that start, and an end not after the start of the row | `409 organization.assignment_conflict` for the first two, nothing written or recorded; an end on or before the next start is accepted and leaves a gap; an end not after the start is `422 organization.structure_refused` (`400` when both dates are sent) | `test/e2e/outlets-assign-end-date.e2e-spec.ts › refuses %s: 409 organization.assignment_conflict, nothing written or recorded`; `test/e2e/outlets-assign-end-date.e2e-spec.ts › accepts an end that stops exactly where the next assignment starts, or before it, leaving a gap that is never filled`; `test/e2e/outlets-assign-end-date.e2e-spec.ts › refuses an end that is not after the start of the current assignment: 422 organization.structure_refused when the start is the default, 400 when it is sent`; `src/modules/organization/application/outlets/plan-assignment.spec.ts › refuses an end that reaches past the start of the next row, or an open end before it, and accepts one that stops on that start` |
| Concurrency: two end dates sent at once for the same current row | Both `200`; one row whose end is the after value of the last record, whose before is the after value of the first | `test/e2e/outlets-assign-end-date.e2e-spec.ts › keeps one consistent row when two end dates are sent in parallel: both answer 200 and the stored end is the one the last record wrote` |
| A bounded assignment `[d1, d2)` over a current open one | The current row is closed at `d1`; from `d2` on nothing is assigned: a gap is never filled | `test/e2e/outlets-assign-end-date.e2e-spec.ts › a bounded assignment [d1, d2) closes the current one at d1 and leaves a gap after d2 that is never filled` |

## Lifecycle and structure reads

### O22 — Deactivate or reactivate a structural entity

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: deactivate an active business unit/legal entity/brand/site/outlet/sales channel with no active children | `200 OK` with the same body as the aggregate and `status = 'inactive'`, one `organization.<kind>.deactivated` record with the status before and after; repeating it answers `200` with no write and no record; a cashier or a member scoped to one business unit is `403 organization.action_forbidden` with one denied record; unknown and foreign are the same `404`; an archived row is `422 organization.structure_refused`. The sales channel has no route (D5, deferred with O19): its lifecycle is proven at the database layer | `test/e2e/structure-lifecycle.e2e-spec.ts › lets the owner deactivate and reactivate it: 200 with the same body as the aggregate, one record each with the status before and after`; `test/e2e/structure-lifecycle.e2e-spec.ts › answers a repeated transition with 200 and the same body, writing and recording nothing more`; `test/e2e/structure-lifecycle.e2e-spec.ts › refuses a cashier and a member whose only role is scoped to a business unit with 403 organization.action_forbidden, one denied record each, nothing written`; `test/e2e/structure-lifecycle.e2e-spec.ts › answers 404 organization.structure_not_found alike for an unknown one and one of another organization, and 400 for a malformed identifier`; `test/e2e/structure-lifecycle.e2e-spec.ts › answers 401 without a session and 403 tenant.context_rejected without the header or with a foreign organization`; `test/e2e/structure-lifecycle.e2e-spec.ts › refuses to change an archived one with 422 organization.structure_refused, writing and recording nothing`; `src/modules/organization/application/structure/change-structure-status.use-case.spec.ts › locks the row before writing, writes the new status, answers the element with it and records the status before and after`; `database/tests/contracts/structure-lifecycle-contracts.ts` › `salesChannelsFollowTheLifecycleAtTheDatabaseLayer` |
| Happy path: reactivate an inactive entity whose parent (if any) is active | `200 OK`, `status = 'active'`, one `organization.<kind>.reactivated` record; the owner reopens a closed branch top-down | `test/e2e/structure-lifecycle.e2e-spec.ts › lets the owner deactivate and reactivate it: 200 with the same body as the aggregate, one record each with the status before and after`; `test/e2e/structure-lifecycle-journey.e2e-spec.ts › closes the bar, the terrace and then the site bottom-up, is refused while a dependent is still open, reopens them top-down and can staff the terrace again with a waiter because its assignments were kept` |
| Adversarial: reactivate a business unit/outlet whose parent is not active | `422 organization.structure_refused` for an outlet under an inactive site, brand or ancestor outlet (`organization_outlet_activation_site`, `_brand`, `organization_outlet_parent_active`), and for an outlet assigned today or from a later date to an inactive business unit or legal entity (`organization_outlet_activation_business_unit`, `_legal_entity`); deactivating an element still used by active ones is `409 organization.structure_in_use` (`23514 organization_<kind>_deactivation_in_use`), with no cascade (D6); a site deactivation racing an outlet opening is serialised by the site row lock | `test/e2e/structure-lifecycle-rules.e2e-spec.ts › refuses to reopen an outlet under an inactive site, brand or parent outlet with 422 organization.structure_refused`; `test/e2e/structure-lifecycle-rules.e2e-spec.ts › refuses to close a site or a brand that still has an active outlet with 409 organization.structure_in_use, writing and recording nothing`; `test/e2e/structure-lifecycle-rules.e2e-spec.ts › refuses to close an outlet that still has an active child outlet with 409 organization.structure_in_use`; `test/e2e/structure-lifecycle-rules.e2e-spec.ts › refuses to close a legal entity or a business unit assigned to an active outlet with 409 organization.structure_in_use, and lets them close once the outlet is closed, keeping its assignments`; `database/tests/contracts/structure-lifecycle-contracts.ts` › `aSiteAndABrandWithActiveOutletsStay`; `database/tests/concurrency/structure-deactivation-concurrency.ts` › `anOutletOpenedFirstKeepsTheSiteActive`; `database/tests/concurrency/structure-deactivation-concurrency.ts` › `aSiteClosedFirstRefusesTheRacingOutlet`; `test/e2e/structure-lifecycle-rules.e2e-spec.ts › refuses to reopen an outlet whose business unit or legal entity is inactive with 422 organization.structure_refused, and reopens it once they are active again`; `database/tests/contracts/structure-lifecycle-contracts.ts` › `anOutletReopensOnlyUnderItsActiveAssignedTargets`; `database/tests/concurrency/structure-deactivation-concurrency.ts` › `aBusinessUnitClosedFirstRefusesTheRacingOutletReopening` |
| Adversarial: deactivate/reassign the last org-wide administrator's scope | The administrator scope is organization-wide, so deactivating any structural entity never touches it; the business unit that holds the last active vertical of the organization is refused with `409 organization.last_vertical_required` (`23514 organization_business_unit_last_vertical`); a vertical turned off while another business unit is being deactivated waits for it and no longer counts its verticals | `test/e2e/structure-lifecycle-rules.e2e-spec.ts › refuses to close the business unit that holds the last active vertical with 409 organization.last_vertical_required`; `database/tests/contracts/structure-lifecycle-contracts.ts` › `theLastVerticalStays`; `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-vertical-locks.repository.integration-spec.ts › makes a reader of the active activations wait for a racing business unit deactivation and leaves that unit out` |
| `identity.role_assignments` — attempt a soft "deactivate" instead of delete | Not applicable; this aggregate has no status column at all (checked against the catalog) | `database/tests/contracts/structure-lifecycle-contracts.ts` › `terminalInvitationsAndAssignmentsKeepTheirEvidence` |
| `identity.invitations` — attempt to delete an accepted or revoked row | Refused by `protect_invitation_lifecycle` (`identity_invitation_terminal`) | `database/tests/contracts/structure-lifecycle-contracts.ts` › `terminalInvitationsAndAssignmentsKeepTheirEvidence` |
| A role scoped to a deactivated site, brand, business unit, legal entity or outlet | Decided (owner, lot B): the assignment is kept as history but grants nothing while the scoped entity or the target outlet is inactive, through every form (direct, descendants, effective assignment); an organization-wide role keeps granting. The decision answers `scope_unavailable` only to a member who held a covering scope, and the plain denial to anybody else; inactive targets are not offered and a new invitation or role assignment to one is refused. No route checks a permission at an outlet yet, so the `403 authorization.scope_unavailable` answer is not reachable over HTTP; it is proven at the contract and database layers | `src/modules/identity/infrastructure/permission-check/permission-check.inactive-scope.integration-spec.ts › grants the waiter at their outlet, answers scope_unavailable while the outlet is closed and grants again once it reopens`; `src/modules/identity/infrastructure/permission-check/permission-check.inactive-scope.integration-spec.ts › answers a stranger to the closed outlet with the plain denial, so the closure leaks to nobody who never held it`; `src/modules/identity/infrastructure/permission-check/permission-check.inactive-scope.integration-spec.ts › keeps granting an organization-wide role at an outlet that is closed`; `database/tests/contracts/inactive-scope-permission-contracts.ts` › `theOldAndTheNewCheckDifferOnlyWhereIntended`; `database/tests/contracts/inactive-scope-permission-contracts.ts` › `strangersNeverLearnAboutAnInactiveScope`; `test/e2e/structure-lifecycle-rules.e2e-spec.ts › refuses an invitation and a role assignment scoped to an inactive place, and offers no role there`; `database/tests/contracts/role-assignment-contracts.ts` › `aRoleWithoutVerticalIsNotGivenAtAnInactivePlace`; `database/tests/shared/site-timezone-differential.ts` › `theSiteDateDecidesTheAssignmentDay` |

### O23 — Read organization structure

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: composite read returns business units, legal entities, brands, sites, and the outlet tree with each outlet's current assignments | `200 OK`, one composed payload | `test/e2e/organization-structure.e2e-spec.ts › lets a restaurant owner build a structure and read it whole in one call with the assignments of today resolved`; `test/e2e/organization-structure-journey.e2e-spec.ts › fills the screen with one context call and one structure read, edits with If-Match and refreshes cheaply` |
| Outlet with no current legal-entity or business-unit assignment | Surfaced as `null`, not hidden or defaulted | `test/e2e/organization-structure.e2e-spec.ts › shows null current assignments for an outlet that has none, never a default`; `test/e2e/organization-structure-pages.e2e-spec.ts › shows a business unit history with a current gap as an empty current assignment` |
| Foreign-tenant structure | Invisible via RLS | `test/e2e/organization-structure.e2e-spec.ts › answers 401 without a session, 403 tenant.context_rejected without the header or for a foreign organization, and 400 for an unknown status`; `database/tests/contracts/structure-version-contracts.ts` › `theVersionRowBelongsToItsOrganization` |
| `status=active` (the default) and `status=all` | The default leaves out every inactive or archived element, `status=all` lists them; each answers its own ETag | `test/e2e/organization-structure.e2e-spec.ts › hides an inactive site and its outlets by default and shows them with status=all` |
| More than 500 outlets | The first 500 with `meta.truncated` `true`; the paged site route lists the rest | `test/e2e/organization-structure.e2e-spec.ts › answers meta.truncated with the first 500 outlets when more than 500 exist, seeded with SQL` |
| ETag after every kind of write, and `If-None-Match` | `304` without a body while nothing changed; every structure or profile write moves the version once per statement, so the previous ETag answers `200` | `test/e2e/organization-structure-preconditions.e2e-spec.ts › moves the ETag forward so the previous If-None-Match no longer answers 304`; `test/e2e/organization-structure.e2e-spec.ts › answers the profile with an ETag that changes after PATCH /organization and 304 while it does not`; `database/tests/contracts/structure-version-contracts.ts` › `eachStatementMovesTheVersionOnce` |
| A write with a stale or foreign `If-Match`, with the current one, and without it | `412 foundation.precondition_failed` and nothing stored; the current ETag (of any representation) and an absent header both succeed; of two writes carrying the same ETag one wins | `test/e2e/organization-structure-preconditions.e2e-spec.ts › refuses a stale If-Match with 412 foundation.precondition_failed storing nothing, and accepts the current ETag`; `test/e2e/organization-structure-preconditions.e2e-spec.ts › accepts the ETag of GET /organization and of status=all as the If-Match of a write, and a write without If-Match`; `test/e2e/organization-structure-preconditions.e2e-spec.ts › refuses the ETag of another organization with the same version as the If-Match of a write`; `test/e2e/organization-structure-preconditions.e2e-spec.ts › lets exactly one of two writes carrying the same If-Match win, the other answering 412, without a deadlock`; `test/e2e/organization-structure-preconditions.e2e-spec.ts › never refuses the write without If-Match that races a conditional one, and moves the version once per success` |
| A member without `organization.account.manage` (cashier) | `403 organization.action_forbidden` on every structure read, nothing recorded | `test/e2e/organization-structure.e2e-spec.ts › refuses a cashier with 403 organization.action_forbidden on every structure read` |
| The site date passes the end of a current assignment (midnight at the site, or a `valid_until` reached) with no write | The structure ETag carries a hash of the current assignments, so the previous `If-None-Match` answers `200` with the new current ids for `status=active` and `status=all`, and the new ETag answers `304`; nothing else moves it, and the profile ETag ignores the site date | `test/e2e/organization-structure-freshness.e2e-spec.ts › stops answering 304 with a stale current legal entity once the site date passes the end of the assignment, with no write in between (structure%s)`; `test/e2e/organization-structure-freshness.e2e-spec.ts › keeps the structure and profile ETags unchanged while nothing changes, and the profile ETag ignores the site date`; `src/modules/organization/application/structure/structure-entity-tag.spec.ts › moves when a current assignment resolves differently under the same version` |
| A dated structure ETag sent as `If-Match` | Accepted while the version is unchanged, whatever its assignment segment; a dated tag of an older version is `412` | `test/e2e/organization-structure-freshness.e2e-spec.ts › accepts a dated structure ETag in If-Match on a write while the version is unchanged`; `src/modules/organization/application/structure/admit-structure-write.spec.ts › admits the dated ETag of a structure read of the current version, whatever the site date, and refuses one of an older version` |
| Current assignment resolved on boundary dates and far time zones | The lateral joins of the composed read answer exactly what `organization.effective_business_unit` and `effective_legal_entity` answer on the site date: an assignment starting today, ending today, tomorrow or yesterday, starting tomorrow, superseded, replaced the same day, or none, on `Pacific/Kiritimati`, `Pacific/Pago_Pago` and `UTC` sites | `src/modules/organization/infrastructure/structure-reads/structure-read.repository.integration-spec.ts › resolves the current business unit and legal entity of every outlet exactly as organization.effective_business_unit and effective_legal_entity do on the site date, across boundary dates and far-east and far-west sites` |

### O24 — Read effective assignment history

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: outlet with three historical assignments and one current | All four rows returned, ordered by `valid_from` | `test/e2e/organization-structure-pages.e2e-spec.ts › returns three past legal entity assignments and the current one ordered by validFrom, and surfaces a gap without filling it` |
| Outlet with a gap (no active assignment for a period) | Gap surfaced, not interpolated | `test/e2e/organization-structure-pages.e2e-spec.ts › returns three past legal entity assignments and the current one ordered by validFrom, and surfaces a gap without filling it`; `test/e2e/organization-structure-pages.e2e-spec.ts › shows a business unit history with a current gap as an empty current assignment` |
| Foreign-tenant outlet | Invisible via RLS | `test/e2e/organization-structure-pages.e2e-spec.ts › answers 404 organization.structure_not_found alike for an unknown site, a site of another organization and their outlets` |
| Paging the outlets of a site | Newest first, no gap or duplicate while outlets are opened; a tampered, foreign-site or foreign-route cursor is `400` with the existing cursor codes | `test/e2e/organization-structure-pages.e2e-spec.ts › pages every outlet of the site newest first with no gap or duplicate while outlets are being opened`; `test/e2e/organization-structure-pages.e2e-spec.ts › refuses a tampered cursor, the cursor of another site and the cursor of another route with the existing cursor failures` |
| More than 500 history rows | The latest 500, still in ascending order, with `meta.truncated` `true`; at most 500 answer every row with `meta.truncated` `false` | `test/e2e/organization-structure-freshness.e2e-spec.ts › answers the latest 500 rows in ascending order with meta.truncated when more exist, seeded with SQL`; `test/e2e/organization-structure-freshness.e2e-spec.ts › answers meta.truncated false with every row when there are at most 500` |

## Memberships and roles

### O25 — List organization members

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: an organization-wide administrator lists members with role and scope (J1 step 7) | `200 OK`, keyset page `(joined_at DESC, id DESC)`, verified identifiers, role code and scope per assignment, and the vertical of each role | `test/e2e/memberships.e2e-spec.ts` › `shows the owner and the members who joined through real invitations with their roles, scopes, verticals and the seats`; `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `lists the members newest first with their roles, the vertical of each role, scopes and verified identifiers` |
| An outlet scope shows its descendants | The assignment's `scope` is `{ kind: 'outlet', id, includeDescendants: true }` | `test/e2e/memberships.e2e-spec.ts` › `shows an outlet scope with its descendants` |
| Only verified, non-revoked identifiers | An unverified or revoked identifier is not listed; there is no name (J1 finding 9) | `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `omits an identifier that is unverified or revoked` |
| `?status=` filters one status; the default is `active,suspended` | A suspended member shows `suspendedAt`, an ended one `endedAt`; an ended member is not in the default list | `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `lists only the requested statuses and gives the dates of a suspended and of an ended member`; `test/e2e/memberships.e2e-spec.ts` › `moves the seats and the lists when a membership is suspended or ends` |
| Seats in the list | `meta.seats.used` = memberships with `status = 'active'` (the count `identity.assert_membership_seat_available` uses; suspended and ended ones do not count), `meta.seats.limit` = `platform.effective_limit(org, 'identity.active_memberships')`, `null` when unlimited or undefined | `test/e2e/memberships.e2e-spec.ts` › `moves the seats and the lists when a membership is suspended or ends`; `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `counts only the active memberships and leaves the limit open when the plan defines none`; `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `reads the seat limit of the organization and counts nobody from another organization`; `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `answers no limit when the seats are unlimited` |
| Open invitations listed | `GET /invitations` answers the open and the expired-but-unclosed invitations with state, expiry, masked identifier, offered roles and scopes and the delivery state (`pending`, `sent`, `failed`, `null`); never the token, `token_hash`, ciphertext or destination | `test/e2e/invitations-list.e2e-spec.ts` › `lists a fresh invitation as open, with its role, scope, masked identifier, inviter and a pending message, and never its token`; `test/e2e/invitations-list.e2e-spec.ts` › `tells pending, sent and failed, shows an expired invitation still waiting, and leaves out the accepted and the revoked ones`; `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `lists an open invitation with its roles, scopes, inviter and no secret` |
| `?state=` filters one invitation state; the default is `open,expired` | `open`, `expired`, `accepted` and `revoked` are derived from the timestamps and each filter lists only its own; accepted and revoked invitations are not in the default list | `test/e2e/invitations-list.e2e-spec.ts` › `filters one state at a time`; `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `keeps an expired open invitation in the default list and leaves the accepted and the revoked ones out` |
| Delivery state of an invitation | The newest outbox message decides: pending or leased is `pending`, published is `sent`, dead-lettered is `failed`, none is `null`; the function answers only for the invitations of the tenant, without a tenant context nothing, and the runtime role still has no `SELECT` on the outbox | `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `tells the delivery state of the latest message: pending, leased as pending, sent, failed, and none`; `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `reports the newest message when an invitation has been sent twice`; `database/tests/contracts/invitation-delivery-states-contracts.ts › J1-4 it answers the state of the newest message of an invitation of the tenant`; `database/tests/contracts/invitation-delivery-states-contracts.ts › J1-4 it answers nothing for an invitation of another organization, even when asked by id`; `database/tests/contracts/invitation-delivery-states-contracts.ts › J1-4 it answers nothing without a tenant context`; `database/tests/contracts/invitation-delivery-states-contracts.ts › J1-4 the runtime role still has no SELECT on the outbox table, which is why the function exists` |
| Adversarial: a member without `organization.account.manage` organization-wide (a cashier) | `403 · identity.action_forbidden` on both routes; no audit record (reads are not audited); nothing of the organization in the body | `test/e2e/memberships-access.e2e-spec.ts` › `answers 403 identity.action_forbidden to a cashier, with no audit record and no member in the body`; `test/e2e/invitations-list-access.e2e-spec.ts` › `answers 403 identity.action_forbidden to a cashier, with no audit record and no invitation in the body`; `src/modules/identity/application/member-directory/list-memberships.use-case.spec.ts` › `refuses a caller without the permission before reading anything and writes no audit record`; `src/modules/identity/application/member-directory/list-invitations.use-case.spec.ts` › `refuses a caller without the permission before reading anything and writes no audit record` |
| Adversarial: no session, and an organization the caller does not belong to | `401 · identity.session_required`; `403 · tenant.context_rejected` | `test/e2e/memberships-access.e2e-spec.ts` › `answers 401 identity.session_required without a session`; `test/e2e/memberships-access.e2e-spec.ts` › `answers 403 tenant.context_rejected to a member of another organization and without a tenant header`; `test/e2e/invitations-list-access.e2e-spec.ts` › `answers 401 identity.session_required without a session`; `test/e2e/invitations-list-access.e2e-spec.ts` › `answers 403 tenant.context_rejected to a member of another organization` |
| Adversarial: another tenant's members or invitations | Never returned: not by id, not through a position taken from one of their rows, and not the caller's own membership of another organization (which the membership policy lets the caller read) | `test/e2e/memberships-access.e2e-spec.ts` › `never shows the members of another organization, whoever asks`; `test/e2e/invitations-list-access.e2e-spec.ts` › `never shows the invitations of another organization`; `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `never shows the members of another organization, even for a position taken from one of their rows`; `src/modules/identity/infrastructure/member-directory/membership-directory.repository.integration-spec.ts` › `does not show the caller own membership of another organization, which the row-level policy lets the caller read`; `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `never lists the invitations or the delivery of another organization`; `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `answers nothing for the invitations of another organization` |
| Adversarial: a tampered cursor, the cursor of another organization or of another filter | `400 · collection.cursor_tampered`; `400 · collection.cursor_incompatible` (the binding carries the route, the organization and the filter) | `test/e2e/memberships-paging.e2e-spec.ts` › `refuses a tampered cursor as collection.cursor_tampered`; `test/e2e/memberships-paging.e2e-spec.ts` › `refuses the cursor of another organization and of another filter as collection.cursor_incompatible`; `test/e2e/invitations-list-access.e2e-spec.ts` › `refuses a tampered cursor, the cursor of another organization and the cursor of another filter` |
| Paging: stable order, no duplicates and no gaps while rows arrive between two pages | Keyset on `(joined_at DESC, id DESC)` and `(created_at DESC, id DESC)`: a row newer than the cursor is not met, an older one is, none is repeated; rows with the same instant are not skipped; backward paging returns the previous page | `test/e2e/memberships-paging.e2e-spec.ts` › `walks every member once, in the same order, when members join between two pages: a newer one is not met, an older one is`; `test/e2e/memberships-paging.e2e-spec.ts` › `pages backward to the rows of the previous page`; `test/e2e/invitations-list-access.e2e-spec.ts` › `walks every invitation once, newest first, while one is created between two pages`; `src/modules/identity/infrastructure/member-directory/membership-directory.repository.paging.integration-spec.ts` › `pages members who joined at the same instant without skipping or repeating any`; `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `pages invitations created at the same instant without skipping or repeating any`; `src/modules/identity/infrastructure/member-directory/directory-queries.spec.ts` › `turns the order around for the previous page and compares the cursor as a pair` |
| Limits and parameters | A limit above 100 or below 1, both cursors, an unknown status or state, an offset and any unknown parameter are `400 · foundation.validation_failed`; the default is 50, 100 is accepted, and the database is asked for at most the limit plus one row | `test/e2e/memberships-paging.e2e-spec.ts` › `refuses a limit above 100, below 1, both cursors, an unknown status and an unknown parameter, and accepts 100`; `test/e2e/invitations-list-access.e2e-spec.ts` › `refuses a limit above 100 or below 1, both cursors, an unknown state and an unknown parameter, and accepts 100`; `src/modules/identity/presentation/dtos/member-directory-query.schema.spec.ts` › `defaults the limit to 50 and leaves the status and cursors unset`; `src/modules/identity/infrastructure/member-directory/directory-queries.spec.ts` › `asks for one row more than the limit, of the organization and the statuses, newest first with the id as tie-break`; `src/modules/identity/infrastructure/member-directory/invitation-directory.repository.integration-spec.ts` › `returns no more than the limit` |
| No ETag, private cache, no audit record and no secret in the body | `Cache-Control: private, no-cache`, no `ETag`; no `token`, hash, ciphertext, nonce, destination or password anywhere in a response; the stored invitation identifier is never returned, only the masked one | `test/e2e/memberships.e2e-spec.ts` › `is privately cached, carries no ETag, writes no audit record and leaks no secret`; `src/modules/identity/application/member-directory/list-invitations.use-case.spec.ts` › `masks the email address and never hands the stored identifier on`; `src/modules/identity/infrastructure/member-directory/directory-queries.spec.ts` › `never reads the token hash, an offset or anything but the delivery function of the outbox` |
| A role assignment with zero access scopes | Cannot exist — the deferred trigger requires at least one; this case cannot occur, not a defensive branch | `database/tests/contracts/role-assignment-contracts.ts` › `J1-7 a role assignment without an access scope cannot commit` |

### O26 — Assign a role to a member

Built in J1 slice J1-7: `POST /memberships/{membershipId}/role-assignments` with `{ roleId, scope }`, answered `201 Created` with `{ roleAssignmentId, membershipId, roleCode, scope }`, or `200 OK` with the same body when the member already holds the role at that scope. Organization-wide `organization.account.manage` and `X-Organization-Id` are required; the audit record `identity.role_assignment.created` carries the role, the member and the scope as the after state and is written in the same transaction. The database backstop of the vertical rule (`access_scopes_vertical_trg`) is proven by `database/tests/contracts/role-assignment-contracts.ts` and `database/tests/concurrency/role-assignment-concurrency.ts`.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: give an active, non-administrative role at a valid scope to an active membership | `201 Created`; the member is listed with the role and keeps working in the organization | `test/e2e/memberships-role-assignment.e2e-spec.ts` › `gives the demoted manager the cashier role organization-wide: they are listed with it and keep working in the organization`; `test/e2e/memberships-role-assignment.e2e-spec.ts` › `gives a role at one site of the organization`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `holds the administration lock, then checks, then audits the new assignment and inserts it, in that order`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `stores the assignment and its scope, recording who gave it` |
| The audit record of the new role | One success record with the role code, the membership and the scope as after attributes, actor the caller | `test/e2e/memberships-role-assignment.e2e-spec.ts` › `audits the new role, the member and the scope as the after state, in the tenant store, with the owner as actor`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `audits the role, the member and the scope it was given at as the after state` |
| Role already assigned to this membership at the same scope | `200 OK` with the same assignment, no write, no second audit record | `test/e2e/memberships-role-assignment.e2e-spec.ts` › `answers the same role at the same scope again with 200 and the same assignment, and leaves no second audit record`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `answers the same role at the same scope with the assignment it already is, writing and auditing nothing` |
| Role already assigned to this membership at another scope | `409 · identity.role_already_assigned`, nothing changes (the unique index is the backstop) | `test/e2e/memberships-role-assignment.e2e-spec.ts` › `refuses the same role at another scope with 409 identity.role_already_assigned and changes nothing`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `refuses the same role at another scope as already assigned`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `refuses the same role twice for one member` |
| Unknown role, role of a different organization, retired role, unknown or foreign scope target | `422 · identity.role_assignment_refused`, all answered alike | `test/e2e/memberships-role-assignment-refusals.e2e-spec.ts` › `answers an unknown role, a role of another organization, an unknown site and a site of another organization all alike: 422 identity.role_assignment_refused`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `refuses a scope target of another organization, storing nothing` |
| Scope names more than one dimension, a target on the whole organization, an unknown field or a malformed id | `400 · foundation.validation_failed`, nothing written | `test/e2e/memberships-role-assignment-refusals.e2e-spec.ts` › `answers %s with 400 and writes nothing`; `test/e2e/memberships-role-assignment-refusals.e2e-spec.ts` › `answers a path that is not an id with 400` |
| A vertical role at a scope where its vertical is active in no covered business unit (D30) | `422 · identity.role_assignment_vertical_inactive`; given once the unit runs the vertical | `test/e2e/memberships-role-assignment.e2e-spec.ts` › `refuses a waiter at a business unit that does not run the restaurant, and gives it once the unit does`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `refuses a vertical role at a scope that does not cover its vertical, writing and auditing nothing`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `refuses, storing nothing, a vertical role at a unit that does not run its vertical: the database backstop` |
| A role that grants organization administration | `403 · identity.role_assignment_privilege_exceeded`, audited as denied; nobody becomes administrator over HTTP | `test/e2e/memberships-role-assignment.e2e-spec.ts` › `never makes anyone an administrator: a role that grants organization administration is refused with 403 and the refusal is audited`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `refuses a role that grants administration, audits the denial and writes nothing: nobody becomes administrator here` |
| Target membership suspended or ended | `409 · identity.membership_not_active`, nothing written | `test/e2e/memberships-role-assignment-refusals.e2e-spec.ts` › `refuses to give a role to a %s member with 409 identity.membership_not_active`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `refuses to give a role to a %s membership, writing and auditing nothing` |
| Unknown membership and membership of another organization | `404 · identity.membership_not_found`, indistinguishable | `test/e2e/memberships-role-assignment-refusals.e2e-spec.ts` › `answers a membership nobody has and the membership of another organization alike: 404 identity.membership_not_found`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `answers not found, writing and auditing nothing, when the membership is not in this organization` |
| A member without organization-wide `organization.account.manage` (a cashier, or one who administers a single business unit) | `403 · identity.action_forbidden`, audited as denied with `permission_missing` | `test/e2e/memberships-role-assignment-access.e2e-spec.ts` › `refuses a cashier who tries to give themself a manager role: 403 identity.action_forbidden, audited as denied, nothing given`; `test/e2e/memberships-role-assignment-access.e2e-spec.ts` › `refuses a member who administers only one business unit, because giving a role needs organization-wide administration`; `src/modules/identity/application/role-assignments/manage-role-assignments.assign.use-case.spec.ts` › `refuses a member without the permission before reading anything, and audits the denial` |
| No session; foreign or missing organization selected as tenant context | `401 · identity.session_required`; `403 · tenant.context_rejected` | `test/e2e/memberships-role-assignment-access.e2e-spec.ts` › `answers 401 identity.session_required without a session and gives nothing`; `test/e2e/memberships-role-assignment-access.e2e-spec.ts` › `answers 403 tenant.context_rejected when the organization is missing or is not the caller one` |
| Two organizations side by side | A role given in one is not seen in the other and the other owner reaches nothing of it | `test/e2e/memberships-role-assignment-access.e2e-spec.ts` › `keeps two organizations apart: a role given in one is not seen in the other, and the other owner reaches nothing of it`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.integration-spec.ts` › `does not see the assignment of a member of another organization` |
| The demoted manager gets a different role and keeps working, end to end | Revoke, give, list and context agree at every step | `test/e2e/role-assignment-journey.e2e-spec.ts` › `shows the owner, at every step, who holds which role at which scope, and the member keeps their seat and their way in` |

### O27 — Change a role assignment's access scope

Built in J1 slice J1-7: `PATCH /role-assignments/{roleAssignmentId}/scope` with `{ scope }`, answered `200 OK` with `{ roleAssignmentId, membershipId, roleCode, scope }`. The old access scope is deleted and the new one inserted in one transaction, never updated in place, and the deferred D21 check is settled inside the repository call so the refusal is `409 · identity.last_administrator_required`, not a failure at commit. The audit record `identity.role_assignment.scope_changed` carries the scope before and after. Same permission and refusals as O26; a role that grants administration is refused only when the new scope is the whole organization (widening), so an administrator can be narrowed.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: narrow a non-last-administrator's scope | `200 OK`, same assignment, old scope row deleted, new one inserted | `test/e2e/role-assignments-scope.e2e-spec.ts` › `narrows a cashier from the whole organization to one site: the answer and the member list say so, and the assignment is the same one`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `replaces the scope of the assignment by a new one, in the same assignment` |
| Widen the scope back | `200 OK` | `test/e2e/role-assignments-scope.e2e-spec.ts` › `widens the cashier back to the whole organization`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `lets an ordinary role be widened to the whole organization` |
| The audit record of the change | One success record with the scope before and after | `test/e2e/role-assignments-scope.e2e-spec.ts` › `audits the scope before and after, in the tenant store, with the owner as actor`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `holds the administration lock, then checks, then audits the old and the new scope and rescopes, in that order` |
| The same scope again | `200 OK` with the same assignment, no write, no audit record | `test/e2e/role-assignments-scope.e2e-spec.ts` › `answers the scope the role already has with 200 and the same assignment, writing and auditing nothing`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `answers the scope it already has with the same assignment, writing and auditing nothing` |
| Adversarial: narrow the organization's last org-wide administrator's scope to one site or outlet | `409 · identity.last_administrator_required`, the scope stays, nothing audited as done | `test/e2e/role-assignments-scope.e2e-spec.ts` › `refuses the last administrator narrowing their own scope: 409 identity.last_administrator_required, the scope stays and nothing is audited as done`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `answers the last administrator narrowing their own scope with the refusal and keeps the organization-wide scope`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `rolls back, with the audit record, when the last administrator would be left without organization-wide access` |
| Narrow scope while another org-wide administrator exists | Allowed | `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `lets an administrator be narrowed while another remains`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `lets an administrator role be narrowed, leaving the last administrator to the database` |
| Demote an administrator to one site | The member loses organization-wide administration at their very next request | `test/e2e/role-assignments-scope.e2e-spec.ts` › `demotes an administrator to one site: they lose the organization-wide administration at their very next request, and cannot be widened back over HTTP` |
| Widen an administrator role to the whole organization | `403 · identity.role_assignment_privilege_exceeded`, audited as denied | `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `refuses to widen an administrator role to the whole organization, auditing the denial: nobody becomes organization-wide administrator here` |
| Two administrators narrowing each other at the same moment | One wins, the other is refused, one organization-wide administrator remains | `test/e2e/role-assignments-scope-race.e2e-spec.ts` › `lets exactly one win: the other is refused and one organization-wide administrator remains`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `serializes two administrators narrowing each other: the second waits, then is refused, and one organization-wide administrator remains` |
| The new scope is refused after the old one was deleted | The old scope is kept: the delete never survives without the insert | `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.writes.integration-spec.ts` › `keeps the old scope when the new one is refused: the delete never survives without the insert` |
| A vertical role at a scope where its vertical is active in no covered business unit (D30) | `422 · identity.role_assignment_vertical_inactive`, the scope it had is kept | `test/e2e/role-assignments-scope.e2e-spec.ts` › `refuses a waiter narrowed to a business unit that does not run the restaurant, keeping the scope it had`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `refuses a vertical role at a scope that does not cover its vertical, writing and auditing nothing` |
| Unknown or foreign scope target; retired role | `422 · identity.role_assignment_refused`, alike | `test/e2e/role-assignments-scope-refusals.e2e-spec.ts` › `answers an unknown site and a site of another organization alike: 422 identity.role_assignment_refused`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `refuses %s as 422 and writes nothing` |
| Unknown assignment and assignment of another organization | `404 · identity.role_assignment_not_found`, indistinguishable | `test/e2e/role-assignments-scope-refusals.e2e-spec.ts` › `answers an assignment nobody has and the assignment of another organization alike: 404 identity.role_assignment_not_found`; `src/modules/identity/infrastructure/role-assignments/role-assignment.repository.integration-spec.ts` › `answers undefined for an unknown id and for the assignment of another organization` |
| Role of a suspended or ended member | `409 · identity.membership_not_active` | `test/e2e/role-assignments-scope-refusals.e2e-spec.ts` › `refuses to change the scope of the role of a %s member with 409 identity.membership_not_active`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `refuses to change the scope of a role of a %s member` |
| Malformed body: no scope, an unknown field, two dimensions, a site without its id | `400 · foundation.validation_failed` | `test/e2e/role-assignments-scope-refusals.e2e-spec.ts` › `answers %s with 400` |
| No session, foreign organization, a member without the permission | `401 · identity.session_required`, `403 · tenant.context_rejected`, `403 · identity.action_forbidden` audited as denied | `test/e2e/role-assignments-scope-refusals.e2e-spec.ts` › `answers 401 without a session, 403 tenant.context_rejected for a foreign organization and a refusal audited as denied for a cashier`; `src/modules/identity/application/role-assignments/manage-role-assignments.rescope.use-case.spec.ts` › `refuses a member without the permission before reading the assignment, and audits the denial` |

### O28 — Revoke a role assignment

Built in J1 slice J1-6: `DELETE /role-assignments/{roleAssignmentId}`, answered `200 OK` with the revoked assignment (the draft said `204`: the answer names what was taken away, like every other command of this journey). Organization-wide `organization.account.manage` and `X-Organization-Id` are required, the audit record `identity.role_assignment.revoked` is written in the same transaction, and the last organization-wide administrator role is protected by D21.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: revoke a non-last-administrator's role assignment | `200 OK` with the assignment id, the membership id and the role code; the assignment and its scope are deleted; the member stays active, keeps the seat and loses that role's permissions at the next request | `test/e2e/memberships-role-revocation.e2e-spec.ts` › `demotes the manager: they lose the permission at their next request, stay a member and keep their seat`; `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `deletes the assignment with its scope and leaves the member active with no roles` |
| Adversarial: revoke the organization's last org-wide administrative role assignment | Refused, `23514 · identity_last_administrator_required` answered `409 · identity.last_administrator_required`; nothing is deleted and no audit record survives | `test/e2e/memberships-last-administrator.e2e-spec.ts` › `refuses the only administrator taking their own administrative role away, and the role stays`; `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `refuses to suspend, end or revoke the role of the only administrator, even themself, and leaves everything as it was`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 the only administrator cannot revoke their own administrative role` |
| Revoke while a second org-wide administrator exists | Allowed | `test/e2e/memberships-last-administrator.e2e-spec.ts` › `lets one administrator take the role away from another while one remains`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 the role assignment of a second administrator can be revoked, leaving the membership active` |
| Concurrency: two simultaneous revokes of two different administrators in the same organization | Serialized by the per-organization administration lock (`identity.lock_administration_tenant`, taken again by the repository before it decides anything); one is refused and one administrator remains | `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `serializes two administrators taking each other role away: one is refused and one administrator remains`; `test/e2e/memberships-last-administrator.e2e-spec.ts` › `leaves exactly one administrator when one ends the other while the other revokes their role`; `database/tests/concurrency/member-control-concurrency.ts` › `NINAKU_MEMBER_CONTROL_CONCURRENCY_OK: ending one administrator while the other loses their role at once leaves exactly one administrator` |
| Revoke a member's only role | Allowed: the member stays active with no role and can do nothing | `test/e2e/memberships-role-revocation.e2e-spec.ts` › `lets a member lose their only role and stay an active member who can do nothing` |
| The revocation is audited with the role and the member it belonged to | One tenant audit record `identity.role_assignment.revoked` with the role code and the membership id as before values | `test/e2e/memberships-role-revocation.e2e-spec.ts` › `audits the revocation with the role and the member it belonged to` |
| Repeated revocation | `404 · identity.role_assignment_not_found`, because the assignment is gone; no second audit record | `test/e2e/memberships-role-revocation.e2e-spec.ts` › `answers a repeated revocation with 404 identity.role_assignment_not_found, because the assignment is gone, and writes no second record` |
| A member without the organization-wide permission | `403 · identity.action_forbidden`, audited as denied with the reason `permission_missing`; nothing changes | `test/e2e/memberships-control-access.e2e-spec.ts` › `refuses a member whose role is %s with 403 identity.action_forbidden, audits the denial and changes nothing`; `test/e2e/memberships-control-access.e2e-spec.ts` › `refuses a member whose administrative permission is scoped to one business unit` |
| Unknown assignment, or one of another organization | The same `404 · identity.role_assignment_not_found` for both; the other organization is untouched | `test/e2e/memberships-control-access.e2e-spec.ts` › `answers the same 404 for another organization's id and for an id nobody has, and leaves the other organization alone`; `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `locks an assignment of the organization and answers its role code and member; a foreign or unknown id answers undefined` |

### O29 — Suspend or end a membership

Built in J1 slice J1-6: `POST /memberships/{membershipId}/suspension`, `.../reactivation` and `.../termination`, each answered `200 OK` with the membership (`status`, `suspendedAt`, `endedAt`). The draft listed only suspension and termination; reactivation is added because a suspension that cannot be undone is a termination. Same permission, header and audit rules as O28 (`identity.membership.suspended`, `.reactivated`, `.ended`, each with the status before and after); a state that already is the requested one answers `200` and writes nothing. A suspended or ended member is shut out of the organization at their very next request, with the refusal a stranger gets (`403 · tenant.context_rejected`): the tenant context is read from the membership on every request, so their sessions are not revoked and their account lives on.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: suspend a non-last-administrator's membership | `200 OK`, `status = 'suspended'`, the member still listed with that status, the seat freed, one audit record with the status before and after | `test/e2e/memberships-suspension.e2e-spec.ts` › `suspends the waiter: the answer, the list, the seat and the audit record all agree`; `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `suspends, reactivates and ends a member, keeping the dates consistent` |
| Adversarial: suspend/end the organization's last active org-wide administrator's membership | Refused, `409 · identity.last_administrator_required`, including the caller themself; nothing changes and no audit record survives | `test/e2e/memberships-last-administrator.e2e-spec.ts` › `refuses the only administrator, even themself, on %s with 409 identity.last_administrator_required and leaves no trace`; `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `refuses to suspend, end or revoke the role of the only administrator, even themself, and leaves everything as it was`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 the only administrator cannot suspend their own membership`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 the only administrator cannot end their own membership` |
| A suspended or ended administrator does not count as an administrator | With the second one suspended the owner is the last again; reactivating the second lets the owner step down | `test/e2e/memberships-last-administrator.e2e-spec.ts` › `counts only active administrators: with the second one suspended the owner is the last again, and reactivating the second lets the owner step down`; `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `counts only active administrators: with the second suspended, the first still cannot go`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 a suspended administrator does not count: the remaining one still cannot suspend themself` |
| Concurrency: two administrators remove each other at the same moment | One request wins and exactly one administrator remains; the other is refused (`409`, or the tenant refusal when they were already removed) | `test/e2e/memberships-last-administrator.e2e-spec.ts` › `leaves exactly one administrator when two administrators remove each other at the same moment (%s, three rounds)`; `src/modules/identity/infrastructure/member-control/membership-control.repository.integration-spec.ts` › `serializes two administrators removing each other: the second waits, then is refused, and one administrator remains`; `database/tests/concurrency/member-control-concurrency.ts` › `NINAKU_MEMBER_CONTROL_CONCURRENCY_OK: two administrators suspending each other at once leave exactly one active administrator` |
| End a membership, then the same person is invited again | A new `identity.memberships` row with fresh roles, never a reopened old one; the ended one stays in the history | `test/e2e/memberships-termination.e2e-spec.ts` › `lets the same person be invited again and accept into a NEW membership with fresh roles, leaving the ended one in the history`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 the person who left can hold a new membership of the same organization` |
| The owner ends the cashier | `200 OK`, `status = 'ended'`, listed with `status=ended`, the seat freed, one audit record | `test/e2e/memberships-termination.e2e-spec.ts` › `ends the cashier: the answer, the list of ended members, the seat and the audit record agree` |
| The removed member is cut off at once | `403 · tenant.context_rejected`, identical to a stranger's; their account and sessions stay | `test/e2e/memberships-termination.e2e-spec.ts` › `cuts the cashier off: they cannot establish the organization context any more, with the same refusal as a stranger, and their old session and token are no use there`; `test/e2e/memberships-suspension.e2e-spec.ts` › `shuts the suspended waiter out of the organization at their very next request, exactly as a stranger is, while their account lives on` |
| The freed seat is taken by a real invitation | An acceptance refused with `409 · identity.seat_limit_reached` is accepted once the owner ends or suspends a member | `test/e2e/memberships-termination.e2e-spec.ts` › `frees the seat for a new hire accepted through a real invitation`; `test/e2e/memberships-suspension.e2e-spec.ts` › `lets an invitation wait for the seat a suspension frees: accepted the moment the owner suspends someone` |
| Reactivate a suspended member over the seat limit | `409 · identity.seat_limit_reached`, the member stays suspended and nothing is audited, until another seat frees | `test/e2e/memberships-suspension.e2e-spec.ts` › `keeps the full team honest: the owner suspends one member, a replacement takes the seat, and bringing the first back is refused until a seat frees`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 reactivating a suspended member over the seat limit is refused`; `database/tests/concurrency/member-control-concurrency.ts` › `NINAKU_MEMBER_CONTROL_CONCURRENCY_OK: two reactivations for the last seat leave one member active and refuse the other` |
| Reactivate a suspended member | `200 OK`, `status = 'active'`, the access returns at once and the seat is taken again | `test/e2e/memberships-suspension.e2e-spec.ts` › `brings the waiter back with a reactivation: the seat is taken again and the access returns at once` |
| Repeated command | `200 OK` with the same state, nothing written, no second audit record | `test/e2e/memberships-suspension.e2e-spec.ts` › `answers a repeated suspension with 200 and the same state, writing no second audit record`; `test/e2e/memberships-suspension.e2e-spec.ts` › `answers a reactivation of a member who is already active with 200, writing nothing`; `test/e2e/memberships-termination.e2e-spec.ts` › `ends a suspended member too, and a repeated ending answers 200 with nothing written` |
| Ending is final | `409 · identity.membership_ended` on suspension or reactivation of an ended membership | `test/e2e/memberships-termination.e2e-spec.ts` › `refuses to change an ended membership with 409 identity.membership_ended on %s, because ending is final`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 an ended membership cannot be reactivated` |
| A member without the organization-wide permission | `403 · identity.action_forbidden`, audited as denied; nothing changes | `test/e2e/memberships-control-access.e2e-spec.ts` › `refuses a member whose role is %s with 403 identity.action_forbidden, audits the denial and changes nothing` |
| Unknown membership, or one of another organization | The same `404 · identity.membership_not_found` for both; the other organization is untouched | `test/e2e/memberships-control-access.e2e-spec.ts` › `answers the same 404 for another organization's id and for an id nobody has, and leaves the other organization alone`; `database/tests/contracts/member-control-contracts.ts` › `J1-6 a member of another organization suspends nothing: the row is invisible to them` |
| The whole journey, seen by the owner at every step | Offerable roles, invitations in every state, members by status and the seats agree after each step, from the first invitation to the retail roles appearing | `test/e2e/members-control-journey.e2e-spec.ts` › `shows the owner, at every step, who is in, in which role, how many seats are used and what every invitation is` |

## Invitations

### O30 — Invite a person to the organization

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: invite an email with one role at one outlet (J1 step 1) | `201 Created`, one open invitation with its role assignments and access scopes, one `invitation` message enqueued for the invited address, one tenant audit record `identity.invitation.created` in the same transaction, and no token, hash or link in the body | `test/e2e/invitations-invite.e2e-spec.ts` › `records one tenant audit record with the masked identifier and the offer, and never the secret`; `test/e2e/invitations-invite.e2e-spec.ts` › `answers 201 with the invitation and nothing that could open it, and stores only the hash of the sealed token`; `test/e2e/invitations-invite.e2e-spec.ts` › `hands the sealed token to the security outbox for the invited address and never stores it in clear`; `test/e2e/invitations-invite.e2e-spec.ts` › `stores one role assignment and one access scope for each offered role`; `src/modules/identity/application/invitations/invite-person.use-case.creation.spec.ts` › `orders the effects: permission, roles, scope, member, open lookup, insert, delivery, audit, commit` |
| Delivery: the worker sends the invitation by email (SES) and by WhatsApp (Wasender) | Message published, ciphertext wiped in the same transaction. The email carries a subject and the text; the WhatsApp message carries the same text with no subject. The sender is faked in every test: the real SES and Wasender connectors are not exercised | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `sends the exact link with the token in the fragment, publishes the message and erases the ciphertext`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `sends the organization name, the link with the token in the fragment and the expiry, then publishes`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `sends the same invitation to a phone number as a subject-less text`; `src/modules/identity/domain/security-outbox/security-outbox-invitation-copy.spec.ts` › `builds the email with a subject, the organization name, the link and the expiry`; `src/modules/identity/domain/security-outbox/security-outbox-invitation-copy.spec.ts` › `builds the WhatsApp text with no subject field` |
| Anti-enumeration: identifier verified on a non-member vs. identifier nobody holds | The same `201`, content type and body shape (compared with the invitation id, expiry and masked address replaced); nothing reveals which. Not compared: response time | `test/e2e/invitations-invite.e2e-spec.ts` › `answers the same 201 shape for an address that belongs to a non-member identity, to a member of another organization and to nobody` |
| Identifier verified on a current member of this organization | `409 · identity.invitee_already_member` for an active or a suspended member, with the address typed in any case; an unverified or revoked identifier and a former member are invited (`201`) | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses an address verified on a %s member of the organization with 409 identity.invitee_already_member`; `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `does not treat an address that is not verified on the member as a member`; `test/e2e/invitations-invite.e2e-spec.ts` › `invites again a person whose membership has ended`; `src/modules/identity/infrastructure/invitations/invitation.repository.integration-spec.ts` › `answers %s: %s`; `src/modules/identity/infrastructure/invitations/invitation.repository.integration-spec.ts` › `answers no for an identifier that is not verified or was revoked`; `src/modules/identity/infrastructure/invitations/invitation.repository.integration-spec.ts` › `answers no for a person of another organization and for an unknown address, alike` |
| Adversarial: a `cashier`, a `waiter` or a `manager` invites | `403 · identity.action_forbidden` for a `cashier`, a `manager`, a `waiter`, a member with no role and a member whose permission is scoped to one business unit; one tenant audit record with outcome `denied` and reason `permission_missing`, no invitation and no message | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses a member whose role is %s with 403 identity.action_forbidden, audits the denial and writes nothing else`; `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses a member whose administrative permission is scoped to one business unit`; `src/modules/identity/application/invitations/invite-person.use-case.spec.ts` › `refuses a member without the permission before reading or writing anything, and audits the denial` |
| Offerable roles for a restaurant-only organization (`GET /offerable-roles`, J1 step 1) | `manager`, `cashier`, `waiter`, `kitchen`, `bar`; never `organization_owner`, `sales_associate`, `stock_keeper`, a retired role or another tenant's custom role | `test/e2e/offerable-roles.e2e-spec.ts` › `lists the neutral and the restaurant roles and no retail role for an organization running only restaurant` |
| Offerable roles for a retail-only organization (J1-2a) | `manager`, `cashier`, `sales_associate`, `stock_keeper`; never `waiter`, `kitchen`, `bar` or `organization_owner` | `test/e2e/offerable-roles.e2e-spec.ts` › `lists the neutral and the retail roles and no restaurant role for an organization running only retail`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `lists the neutral roles and the retail roles, and no restaurant role, for an organization running only retail` |
| A unit activates `restaurant` (through the O11 route), then the last unit running it turns it off (J1-2a) | The restaurant roles appear organization-wide and at that unit only, and are gone at the next call after the deactivation; at a retail unit they never appear even while another unit runs `restaurant` | `test/e2e/offerable-roles.e2e-spec.ts` › `adds the restaurant roles once a unit activates restaurant, only at that unit and organization-wide`; `test/e2e/offerable-roles.e2e-spec.ts` › `drops the restaurant roles at the next call once the last unit running restaurant turns it off`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `lists the union organization-wide and only the unit verticals at a business unit scope`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `does not list restaurant roles at a unit without restaurant even when another unit runs it`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `stops listing the roles of a vertical at the next call once its last unit deactivates it` |
| Offerable roles at a site, a brand, a legal entity or an outlet (J1-2a) | The roles of the verticals active in the units the scope covers, with `includeDescendants` read for an outlet | `test/e2e/offerable-roles.e2e-spec.ts` › `answers the roles of the units a site, a brand, a legal entity and an outlet cover`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `lists at a site, a brand, a legal entity and an outlet the verticals of the units they cover` |
| The list and `identity.role_offerable_at_scope` disagree (J1-2a) | They never do: every visible role is listed at a scope exactly when the SQL function says it is offerable there, for the organization-wide scope and for each of the five scope kinds | `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `agrees with identity.role_offerable_at_scope for every visible role at every scope kind` |
| Adversarial: the list carries `organization_owner`, a role that grants `organization.account.manage`, a retired role or a custom role of another organization (J1-2a) | None of them is listed; an active custom role of the organization is, with a null vertical; no foreign identifier or name is in the body | `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `never lists organization_owner, nor any role that grants organization.account.manage`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `never lists a custom role that is %s, and lists an active one with no vertical`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `never lists another organization's custom role`; `test/e2e/offerable-roles.e2e-spec.ts` › `lists an active custom role of the organization and no retired one`; `test/e2e/offerable-roles.e2e-spec.ts` › `carries nothing of another organization: its custom role never appears and no foreign id is in the body` |
| Shape of the offerable roles answer (J1-2a) | `200` `{ data, meta }` with `roleId`, `code`, `name` and `verticalCode` (null when neutral) per item, ordered by name and then id, the same on every call, `Cache-Control: private, no-cache`, no `ETag` and `If-None-Match` ignored (the list changes when a vertical is toggled) | `test/e2e/offerable-roles.e2e-spec.ts` › `answers a data envelope of flat items, privately cached and without an ETag`; `test/e2e/offerable-roles.e2e-spec.ts` › `ignores If-None-Match: the list is never answered 304`; `test/e2e/offerable-roles.e2e-spec.ts` › `answers the roles ordered by name and then id, the same on every call`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `carries the role id, the code, the English name and the vertical code, null for a neutral role`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `orders the roles by name and then by id, the same on every call` |
| Offerable roles read without a session, or for an organization the caller does not belong to (J1-2a) | `401 · identity.session_required`; `403 · tenant.context_rejected` (no header, or a foreign organization) | `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `answers 401 identity.session_required without a session`; `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `answers 403 tenant.context_rejected without an organization header and for an organization the caller does not belong to` |
| Adversarial: a member without `organization.account.manage` organization-wide reads the offerable roles (a member with no role, a `cashier`, a `manager`, a `waiter`, or one whose permission is scoped to a business unit) (J1-2a) | `403 · organization.action_forbidden`, the same whether the scope exists or not, and no audit record is written (a read); a member holding the permission organization-wide is answered `200` | `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `refuses a member with no role with 403 organization.action_forbidden and writes no audit record`; `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `refuses a member whose only role is %s: none of the seven system roles grants the permission`; `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `refuses a member whose permission is scoped to one business unit`; `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `answers a member holding the permission organization-wide`; `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `answers a member without the permission 403 whether the target exists or not`; `src/modules/organization/application/offerable-roles/list-offerable-roles.use-case.spec.ts` › `refuses a caller without the permission, reads nothing and writes no audit record`; `src/modules/organization/application/offerable-roles/list-offerable-roles.use-case.spec.ts` › `answers a caller without the permission the same way whether the scope exists or not`; `src/modules/organization/application/offerable-roles/list-offerable-roles.use-case.spec.ts` › `asks for organization.account.manage of the caller before anything else, organization-wide` |
| Adversarial: a scope target of another organization, or one that does not exist (J1-2a) | `404 · organization.structure_not_found` for every scope kind, with the same detail for a foreign and an unknown identifier, nothing listed | `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `answers 404 organization.structure_not_found for another organization's target of every kind and for an unknown id, alike`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `finds the targets of its own organization for every scope kind and refuses unknown ones`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `does not find another organization's target of any kind`; `src/modules/organization/infrastructure/offerable-roles/offerable-role.repository.integration-spec.ts` › `always finds the organization-wide scope`; `src/modules/organization/application/offerable-roles/list-offerable-roles.use-case.spec.ts` › `refuses a scope target that does not exist in the organization and lists nothing`; `src/modules/organization/application/offerable-roles/list-offerable-roles.use-case.spec.ts` › `asks the repository about the scope even when it is organization-wide, which the repository always finds` |
| Offerable roles with a malformed scope (J1-2a) | `400 · foundation.validation_failed`: a missing or unknown `scopeKind`, a `scopeId` with `scopeKind=organization`, a missing or malformed `scopeId`, `includeDescendants` outside an outlet or malformed, an unknown parameter | `test/e2e/offerable-roles-refusals.e2e-spec.ts` › `answers 400 foundation.validation_failed for %s`; `src/modules/organization/presentation/dtos/offerable-roles-query.schema.spec.ts` › `reads the organization-wide scope with no identifier`; `src/modules/organization/presentation/dtos/offerable-roles-query.schema.spec.ts` › `reads a %s scope with its identifier and no descendants by default`; `src/modules/organization/presentation/dtos/offerable-roles-query.schema.spec.ts` › `reads includeDescendants only for an outlet`; `src/modules/organization/presentation/dtos/offerable-roles-query.schema.spec.ts` › `refuses includeDescendants=true for a %s scope`; `src/modules/organization/presentation/dtos/offerable-roles-query.schema.spec.ts` › `refuses %s` |
| Adversarial: a retail role offered in a restaurant-only organization, or a vertical role at a scope whose business units do not run its vertical (D30) | `422 · identity.invitation_role_vertical_inactive`, nothing written; the SQL trigger on `identity.invitation_access_scopes` refuses the same row | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses a restaurant role in a retail-only organization and a retail role in a restaurant-only one with 422 identity.invitation_role_vertical_inactive`; `src/modules/identity/infrastructure/invitations/invitation.repository.writes.integration-spec.ts` › `refuses a vertical role where its vertical is not active with the vertical failure` |
| A neutral role at any scope, and a vertical role organization-wide when one business unit runs its vertical | `201`: a neutral role organization-wide, at a business unit and at an outlet with its descendants, and `sales_associate` organization-wide in a retail organization | `test/e2e/invitations-invite.e2e-spec.ts` › `answers 201 with the invitation and nothing that could open it, and stores only the hash of the sealed token`; `test/e2e/invitations-invite.e2e-spec.ts` › `stores one role assignment and one access scope for each offered role`; `test/e2e/invitations-invite.e2e-spec.ts` › `records one tenant audit record with the masked identifier and the offer, and never the secret` |
| A retired system role (`inactive` or `archived`) | `422 · identity.invitation_refused` for a role that is not `active`, proven with a custom role set `inactive` (the same status check serves a retired system role, which is not exercised because system roles are shared global state) | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses an unknown role, a retired role and the custom role of another organization alike with 422 identity.invitation_refused` |
| The seven system roles after the seed runs twice | Exactly seven rows, no `role_permissions` rows outside the four catalog permissions and the two assets media permissions, `identity.effective_permission` `false` for every other code for a member holding only them | `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R running the seed twice leaves seven rows and overwrites no hand-loaded row`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R the seven roles hold no permission outside the catalog and assets media ones`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R a member holding all seven roles has no effective permission outside the catalog and assets media` |
| Adversarial: `ninaku_runtime` inserts, updates or deletes a system role | Refused (`42501`, no write privilege on `identity.roles`) | `database/tests/contracts/roles-by-vertical-contracts.ts` › `J1-R ninaku_runtime cannot insert a system role`; › `J1-R ninaku_runtime cannot update a system role`; › `J1-R ninaku_runtime cannot delete a system role` |
| Adversarial: a tenant session without `organization.account.manage` calls `identity.enqueue_invitation_message` directly | `42501` (`identity_invitation_inviter_forbidden`), nothing enqueued; a member with the permission organization-wide still enqueues, and another organization's invitation is still `identity_invitation_not_open` | `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b a member without organization.account.manage cannot enqueue an invitation message`; `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b the refusal is a 42501 and nothing is enqueued`; `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b a member with organization.account.manage organization-wide can enqueue`; `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b an invitation of another organization is still not open for the caller` |
| Adversarial: `organization_owner` or any role granting `organization.account.manage` offered | `403 · identity.invitation_privilege_exceeded`, audited as denied | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses organization_owner and a custom role that grants administration with 403 identity.invitation_privilege_exceeded, audited as denied`; `src/modules/identity/infrastructure/invitations/invitation.repository.integration-spec.ts` › `reports which roles grant administration and hides the roles of another organization`; `src/modules/identity/infrastructure/invitations/invitation.repository.writes.integration-spec.ts` › `refuses a role that grants administration with the privilege failure` |
| Role or scope target of another organization, inactive or unknown | `422 · identity.invitation_refused`, with the same detail for each cause; nothing is written | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses an unknown role, a retired role and the custom role of another organization alike with 422 identity.invitation_refused`; `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses a scope target of another organization, an unknown one and an inactive one alike with 422 identity.invitation_refused`; `src/modules/identity/infrastructure/invitations/invitation.repository.integration-spec.ts` › `accepts an active target of the organization and refuses a foreign, an unknown and an inactive one`; `src/modules/identity/infrastructure/invitations/invitation.repository.writes.integration-spec.ts` › `refuses a scope target of another organization with the refusal failure and stores nothing` |
| Open, unexpired invitation for the identifier with another configuration | `409 · identity.invitation_already_open` | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `refuses a second request for an open invitation of another role or scope with 409 identity.invitation_already_open and keeps the first`; `src/modules/identity/application/invitations/invite-person.use-case.spec.ts` › `refuses an open invitation of another configuration and writes nothing` |
| Exact retry, and two identical requests raced | `200` with the same invitation; one row and one message exist | `test/e2e/invitations-invite.e2e-spec.ts` › `answers 200 with the same invitation and sends no second message when the same request is repeated, whatever the spelling of the address`; `test/e2e/invitations-invite-hardening.e2e-spec.ts` › `lets two identical requests race into one invitation, one message and one audited creation` |
| An expired, never-revoked invitation for the identifier | Revoked in the same transaction (audited `identity.invitation.revoked`, reason `expired_replaced`, its pending message withdrawn), then the new one is created (`201`) | `test/e2e/invitations-invite.e2e-spec.ts` › `revokes an expired open invitation for the same address and replaces it in the same request`; `src/modules/identity/infrastructure/invitations/invitation.repository.writes.integration-spec.ts` › `frees the identifier once the invitation is revoked, so a new one can be stored`; `src/modules/identity/application/invitations/invite-person.use-case.creation.spec.ts` › `revokes an expired open invitation, withdraws its message and replaces it in the same transaction` |
| Open invitations at the plan's `identity.pending_invitations` | `409 · identity.invitation_pending_limit_reached` | `test/e2e/invitations-invite-limits.e2e-spec.ts` › `refuses the twenty-first open invitation with 409 identity.invitation_pending_limit_reached and stores nothing for it`; `src/modules/identity/infrastructure/invitations/security-outbox-invitation-delivery.integration-spec.ts` › `refuses the open invitation beyond the plan limit and leaves no message` |
| 31st invitation in 24 hours, or a second message to one identifier within an hour | `429 · identity.invitation_rate_limited` with `Retry-After` from the database hint: for the per-address hour the time until its oldest message leaves the window, for the daily organization cap the time until the oldest invitation of the window leaves it (at least one second) | `test/e2e/invitations-invite-limits.e2e-spec.ts` › `refuses a second message to the same address within the hour with 429 identity.invitation_rate_limited and a Retry-After`; `test/e2e/invitations-invite-limits.e2e-spec.ts` › `refuses the thirty-first invitation of the day with 429 identity.invitation_rate_limited`; `src/modules/identity/infrastructure/invitations/security-outbox-invitation-delivery.integration-spec.ts` › `refuses a second message to the same address within the hour and says how long to wait`; `src/modules/identity/infrastructure/invitations/security-outbox-invitation-delivery.integration-spec.ts` › `refuses the thirty-first invitation of the day for the organization and says how long to wait`; `src/modules/identity/infrastructure/invitations/invitation-failure.spec.ts` › `carries the wait the database computed for the daily cap of the organization`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-1 the daily organization cap refuses with a retry_after_seconds hint of the time until the oldest invitation leaves the window` |
| Invite by phone (J1-2) | `201` with the masked number (`+***67`); the outbox destination is the E.164 number | `test/e2e/invitations-invite.e2e-spec.ts` › `invites by phone and answers the masked number` |
| Hostile or malformed body (J1-2) | `400 · foundation.validation_failed` and nothing written: an unknown field, an unknown identifier type, an address that is not an email, a phone that is not E.164, a NUL character, no role or more than five, the same role twice, a scope of an unknown kind, an organization scope with a target, a target-less site scope, a scope with two dimensions, descendants outside an outlet, an expiry outside 1 to 30 days or not an integer | `test/e2e/invitations-invite-hardening.e2e-spec.ts` › `answers 400 foundation.validation_failed for %s and writes nothing` |
| Expiry edges (J1-2) | One day and thirty days are accepted and stored as given; the default is seven days | `test/e2e/invitations-invite-hardening.e2e-spec.ts` › `accepts one day and thirty days as the edges of the expiry`; `test/e2e/invitations-invite.e2e-spec.ts` › `answers 201 with the invitation and nothing that could open it, and stores only the hash of the sealed token` |
| Invite without a session, or for an organization the caller does not belong to (J1-2) | `401 · identity.session_required`; `403 · tenant.context_rejected` (no header, or a foreign organization); nothing written | `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `answers 401 identity.session_required without a session`; `test/e2e/invitations-invite-refusals.e2e-spec.ts` › `answers 403 tenant.context_rejected without an organization header and for an organization the caller does not belong to` |
| More than 20 invitation requests in 10 minutes from one IP (J1-2) | `429 · rate_limit.exceeded` on the twenty-first, counted before the body is read | `test/e2e/invitations-invite-hardening.e2e-spec.ts` › `answers 429 rate_limit.exceeded to the twenty-first request from one address within ten minutes` |
| The sealed message (J1-2) | One `invitation` message to the destination of the invitation row, its ciphertext openable only with the cipher, never the token in clear | `src/modules/identity/infrastructure/invitations/security-outbox-invitation-delivery.integration-spec.ts` › `enqueues one invitation message to the destination of the invitation row, sealed so that only the cipher opens it`; `test/e2e/invitations-invite.e2e-spec.ts` › `hands the sealed token to the security outbox for the invited address and never stores it in clear` |
| A rollback after the message was scheduled (J1-2) | The message is gone with the invitation | `src/modules/identity/infrastructure/invitations/security-outbox-invitation-delivery.integration-spec.ts` › `leaves no message when the transaction rolls back after the message was scheduled` |
| Withdrawing the message of an invitation (J1-2) | The pending message is dead-lettered as `invitation_revoked` with its ciphertext wiped; another organization's message is untouched | `src/modules/identity/infrastructure/invitations/security-outbox-invitation-delivery.integration-spec.ts` › `dead-letters the pending message of the invitation and wipes its ciphertext`; `src/modules/identity/infrastructure/invitations/security-outbox-invitation-delivery.integration-spec.ts` › `never withdraws the message of another organization invitation` |
| The repository reads and writes (J1-2) | The invitation, one role assignment and one access scope per offer are stored with the SHA-256 hash and the expiry; a lookup finds the open invitation with its roles and scopes, tells an expired one apart and ignores a revoked one | `src/modules/identity/infrastructure/invitations/invitation.repository.writes.integration-spec.ts` › `stores the invitation, one role assignment and one access scope per offer, and answers the expiry`; `src/modules/identity/infrastructure/invitations/invitation.repository.writes.integration-spec.ts` › `finds an open invitation with its roles and scopes, and tells an expired one apart`; `src/modules/identity/infrastructure/invitations/invitation.repository.writes.integration-spec.ts` › `ignores an invitation that was already accepted or revoked` |
| Fourth invitation message to one identifier in 24 hours across organizations | `201`; the message is deferred (`available_at` later), never refused | Over HTTP: `test/e2e/invitations-invite-limits.e2e-spec.ts` › `answers the same 201 and only defers the message when three other organizations already wrote to that address today` |
| Organization B invites an address that has a pending invitation message from organization A | A's message is not superseded; both are delivered | `database/tests/contracts/invitation-delivery-contracts.ts` › `J1-1 organization B can invite an address that organization A already invited`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `delivers both invitation messages when two organizations invite the same address` |
| Adversarial: organization B invites and revokes the same person repeatedly | Withdrawn and dead-lettered messages do not count toward the cross-organization cap; A's message is never deferred past one hour before its `expires_at` | `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b three revoked invitations to the same person do not defer another organization`; `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b a fourth hostile withdrawal and one live message still leave the slot free`; `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b dead-lettered messages plus one live message leave the slot free`; `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b the deferral never passes one hour before the invitation expires`; `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b an invitation about to expire is not scheduled in the past nor deferred` |
| Adversarial: an invitation is revoked or expires while its message is leased | After the lease expires the message is never claimed again, a retryable failure dead-letters it and wipes the ciphertext instead of requeuing it; a message already reported as published stays published (a message already handed to the provider cannot be recalled: its token is refused at acceptance) | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b a revoked invitation whose lease expired is dead-lettered by the next claim, never delivered again`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b a retryable error on an invitation that is ${how} dead-letters and wipes instead of returning to pending`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b a message already handed to the provider stays published and wiped after the revocation`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b an explicit failure keeps the class the worker reported`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `never sends the message of an invitation revoked before the claim, and dead-letters it with the ciphertext erased`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `fails closed when the invitation is revoked while its message is leased: a failed send is dead-lettered, never requeued`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `dead-letters an invitation that expired while it waited, as invitation_expired, without sending anything` |
| Staff lists invitation dead letters | Listed by `identity.list_security_outbox_dead_letters_as_staff` with its failure class, a masked destination and the invitation expiry, in the unchanged result shape and without the address, the token or the ciphertext. Since J1-W the TypeScript staff mapping accepts the `invitation` purpose and the staff route answers `200` for these rows (the response enum gained `invitation`, so the OpenAPI major version moved to 4.0.0) | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the staff dead-letter listing shows an invitation dead letter with its class and the invitation expiry`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the staff listing keeps its shape and never carries the address, the token or the ciphertext`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.invitation.integration-spec.ts` › `lists an invitation dead letter in the same shape as any other, masked, without a token, a ciphertext or the address`; `test/e2e/platform-admin-security-outbox-invitation.e2e-spec.ts` › `lists an invitation dead letter with a 200, the invitation purpose and no secret` |
| The worker has no invitation link base URL (J1-W) | Optional in every environment: the worker boots in `production` and `staging` without `INVITATION_LINK_BASE_URL` and logs one startup warning naming the variable (no value). Without it, an invitation message is not opened and not sent: it is reported as a retryable failure with the class `invitation_link_unconfigured` (SQL accepts any non-empty class), retries with the normal bounded backoff and is dead-lettered after the last attempt; the four OTP purposes are unaffected. Development and test fall back to `http://localhost:3000` | `src/foundation/config/schema/invitation-link-base-url.schema.spec.ts` › `lets the worker boot in %s without it, so OTP delivery never depends on an invitation setting`; `src/foundation/config/app-config.service.spec.ts` › `reports no invitation link base URL in %s when none is set, never a localhost one`; `src/foundation/config/app-config.service.spec.ts` › `falls back to the local machine for the invitation link base URL in %s when none is set`; `src/modules/identity/presentation/jobs/security-outbox-dispatch.job.invitation-link.spec.ts` › `logs exactly one warning that names the variable and carries no value when it is not configured`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `neither opens the token nor sends, and reports a retryable failure with its own class and the normal backoff`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `dead-letters the message once the bounded attempts are exhausted`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `leaves every OTP purpose completely unaffected`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `still sends an invitation once the variable is configured`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `without the base URL sends nothing, opens nothing, keeps the sealed token and retries with its own failure class` |
| The worker claims an invitation message | It is mapped as an invitation (no challenge), sent with the organization name, never dead-lettered as malformed. A row that names neither a challenge nor an invitation, an invitation row without its organization name, and a row that mixes the two are dead-lettered as `malformed_claim_row`, and an unknown purpose still is | `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `maps an invitation row with a null challenge id to an invitation message carrying the organization name and the invitation expiry`; `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `maps a challenge row with a null organization name and a null invitation id unchanged`; `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `dead-letters as malformed a row that $what, and never lets it reach a sender`; `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `still dead-letters an unknown purpose as malformed`; `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `claims zero rows without recording anything`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `returns an empty tally and sends nothing when the claim returns no rows` |
| The token never leaves the server | Absent from every response body and header, every log line and every audit record of the invite route, including the retry and a refusal; the stored `token_hash` is the SHA-256 of the sealed token and the ciphertext is not the token. The worker side is proven by the worker tests of the delivery row above (no log line, record call or failure class carries the token or the link, even when the provider error echoes them); the accept response waits for J1-3. Not proven: a trace attribute or an error body carrying it (no code path sets one) | `test/e2e/invitations-invite.e2e-spec.ts` › `answers 201 with the invitation and nothing that could open it, and stores only the hash of the sealed token`; `test/e2e/invitations-invite.e2e-spec.ts` › `records one tenant audit record with the masked identifier and the offer, and never the secret`; `test/e2e/invitations-invite-hardening.e2e-spec.ts` › `never writes the token, its hash or the outbox ciphertext to a log line, whatever the answer`; `src/modules/identity/application/invitations/invite-person.use-case.creation.spec.ts` › `never carries the token or its hash in the outcome or in an audit record` |
| Database layer: the outbox admits the `invitation` purpose only with an invitation and no challenge | Admitted with exactly one source; a message naming a challenge instead, both, neither, or a sign-up message naming an invitation is refused by `security_outbox_messages_source_chk`; a message cannot be re-pointed at another invitation | `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 the invitation purpose is admitted with an invitation and no challenge`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 an invitation message may not name a challenge instead`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 a message may not name both a challenge and an invitation`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 a message may not name neither a challenge nor an invitation`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 a sign-up message may not point at an invitation`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 a message cannot be re-pointed at another invitation` |
| Database layer: organization B invites an address that organization A already invited | B's message is enqueued; A's pending message keeps its state and its ciphertext; two pending invitation messages to one address coexist (the worker delivering both is proven with the worker slice) | `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 organization B can invite an address that organization A already invited`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 organization B's invitation message does not supersede A's pending one`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 two pending invitation messages to one address coexist at the table level` |
| Database layer: a reset message and an invitation message to the same address | Each leaves the other alone: the newer reset supersedes only the older reset, and an invitation message leaves a pending reset in place | `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 a reset message to an invited address supersedes only the older reset`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 an invitation message leaves a pending reset to the same address alone` |
| Database layer: a retryable delivery failure of one organization's message while another organization's message to the same address is pending | The failed message returns to `pending` with its failure class; it is not dead-lettered as `superseded_by_resend` | `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 a retryable failure of A's message is not a supersede by B's pending message` |
| Database layer: the destination of an invitation message | Read from the invitation row (`normalized_value` and `identifier_type`); `identity.enqueue_invitation_message` takes no destination argument, so the API can never redirect a message | `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 enqueue returns the message id and stores a pending invitation message`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the destination comes from the invitation row, never from the caller` |
| Database layer: enqueue for an invitation that is not the caller's open invitation, or from a caller that may not | Refused with `23514 · identity_invitation_not_open` for a revoked, accepted, expired, unknown or another organization's invitation (the last two indistinguishable); `42501` without a tenant context or for `ninaku_worker`; refused above READ COMMITTED; nothing is left behind | `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 enqueue refuses a ${label} invitation`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 enqueue refuses another organization's open invitation exactly like an unknown one`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 enqueue refuses a session with no tenant context`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the worker cannot enqueue an invitation message`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 enqueue refuses to run above READ COMMITTED, where its counts could be stale`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 a refused enqueue left no message behind` |
| Database layer: open invitations against the plan limit `identity.pending_invitations` | Beyond the limit: `23514 · identity_invitation_pending_limit`; revoked, expired and accepted invitations do not count; Pionero (version 3) declares 20 in its plan; with no plan row the cap is still 20; an unlimited override lifts it | `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 revoked, expired and accepted invitations do not count against the limit`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the open invitation beyond the plan limit is refused`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 revoking an open invitation frees a slot under the limit`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 a Pionero organization takes its seat limit and its open-invitation limit of 20 from its plan`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 on Pionero the twenty-first open invitation is refused`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 an organization whose plan declares no open-invitation row has no effective open-invitation limit`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 with no plan row the twentieth open invitation is accepted`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 with no plan row the twenty-first open invitation is refused`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 an unlimited override lifts the open-invitation cap` |
| Database layer: new invitations per organization in a rolling 24 hours | The 30th is accepted and the 31st is refused with `23514 · identity_invitation_org_rate_limit`; invitations older than 24 hours leave the window | `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the thirtieth new invitation within 24 hours is accepted`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the thirty-first new invitation within 24 hours is refused`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 invitations older than 24 hours leave the window` |
| Database layer: one message per identifier per organization per hour | A second message within the hour is refused with `23514 · identity_invitation_identifier_rate_limit` and a `retry_after_seconds` hint; another organization and a message older than the hour are not held | `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 a second message to the same identifier within the hour is refused with a retry hint`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the same identifier in another organization is not held by this hour rule`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 a message older than the hour no longer holds the identifier` |
| Database layer: the fourth invitation message to one identifier in 24 hours across organizations | Enqueued, never refused, with `available_at` at the soonest instant the rule allows; the caller's answer is the same as for an immediate message; the fifth queues behind the fourth; messages older than 24 hours no longer count | `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the fourth message in 24 hours across organizations is enqueued, not refused`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the deferred message waits for the soonest instant the rule allows`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 a deferred enqueue answers exactly like an immediate one`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the fifth message queues behind the fourth, in a deterministic order`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 the claim does not return a deferred message early`; `database/tests/contracts/invitation-delivery-limits-contracts.ts › J1-1 messages older than 24 hours no longer count toward the daily cap` |
| Database layer: the worker claim serves an invitation message | Returned with no challenge and the invitation's expiry; not returned before `available_at`; not claimed twice while its lease holds; claimed again once the lease expired | `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 the claim returns an invitation message with the invitation expiry and no challenge`; `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 the claim does not return an invitation message before its available_at`; `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 a leased invitation message is not claimed twice while its lease holds`; `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 an invitation message whose lease expired is claimed again` |
| Database layer: the ciphertext of an invitation message that can no longer be used | The purge dead-letters it and wipes the ciphertext once its invitation expired (`invitation_expired`) or was revoked (`invitation_revoked`); the message of an open invitation is left alone | `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 the purge wipes the ciphertext of a message whose invitation expired`; `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 the purge wipes the ciphertext of a message whose invitation was revoked`; `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 the purge leaves the message of an open invitation alone` |
| Database layer: withdrawing the message of an invitation | Dead-letters a pending message as `invitation_revoked` and wipes it; a published message is untouched; another organization, a session with no tenant context and the worker cannot withdraw | `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 withdrawing dead-letters a pending invitation message as invitation_revoked and wipes it`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 withdrawing is a no-op for a message that was already published`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 another organization cannot withdraw this organization's message`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 withdrawing without a tenant context is refused`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 the worker cannot withdraw an invitation message` |
| Database layer: platform staff reissue an invitation message | Refused, `23514 · security_outbox_reissue_invitation_message`, on open and on complete: only the inviting organization reissues. Since J1-W the TypeScript layer maps that refusal to the staff refusal reason `invitation_message` (`422 · platform.security_outbox_reissue_refused`) and audits the denial | `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 staff cannot open a reissue of an invitation message`; `database/tests/contracts/invitation-delivery-worker-contracts.ts › J1-1 staff cannot complete a reissue of an invitation message`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.invitation.integration-spec.ts` › `refuses staff the reissue of an invitation dead letter with the invitation reason and queues nothing`; `test/e2e/platform-admin-security-outbox-invitation.e2e-spec.ts` › `refuses the reissue of an invitation dead letter with a clean 422, audits the denial and queues nothing` |
| Database layer: three live messages to one identifier in 24 hours | The fourth is deferred by 24 hours from the oldest of the three; `pending`, `leased` and `published` messages all count | `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b three live messages (pending, leased, published) defer a fourth by 24 hours` |
| Database layer: the cross-organization slot query reads a bounded history | An invitation older than the 31-day maximum validity does not take a slot | `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b the slot query is bounded by the age of the identifier history` |
| Database layer: the claim returns the organization name and the invitation id | Appended after the unchanged columns for an invitation message, `NULL` for a challenge message; the worker's own column list still reads the extended claim | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim returns the organization name and the invitation id of an invitation message`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim returns no organization name and no invitation id for a challenge message`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the existing claim columns keep their order and the new ones come last`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the worker claim query, which names its columns, still reads the extended claim` |
| Database layer: the claim meets the message of a revoked, accepted or expired invitation | Dead-lettered (`invitation_revoked`, `invitation_accepted`, `invitation_expired`) with the ciphertext wiped, never leased; the message of an open invitation is still leased | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim dead-letters and wipes the message of an invitation that is ${message.kind} instead of leasing it`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim still leases the message of an open invitation` |
| Database layer: the purge meets the message of an accepted invitation | Dead-lettered as `invitation_accepted` with the ciphertext wiped | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the purge retires and wipes the message of an accepted invitation` |
| Database layer: no new delivery attempt after a revocation or expiry | A retryable error never returns the message to `pending`, so no later claim can offer it | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b no new delivery attempt is offered after an invitation that is ${how} failed` |
| Database layer: the invitation an invitation replaces | `replaces_invitation_id` cannot be changed after creation (`identity_invitation_provenance`) | `database/tests/contracts/invitation-delivery-review-limits-contracts.ts` › `J1-1b the invitation this one replaces cannot be set after creation` |
| Database layer: an invitation offers a role that manages the organization account | Refused, `23514 · identity_invitation_role_grants_administration`, for `organization_owner`, for another global role and for a custom role that grants `organization.account.manage`; a role that does not is still admitted | `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 an invitation cannot offer organization_owner`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 an invitation cannot offer a global role that manages the account`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 an invitation cannot offer a custom role that manages the account`; `database/tests/contracts/invitation-delivery-contracts.ts › J1-1 an invitation can still offer a role that does not manage the account` |
| Database layer: the role vertical column | `identity.roles.vertical_code` is a nullable `varchar(80)` (null is neutral) with the foreign key `roles_vertical_fk`; a custom role of an organization with a vertical is refused by `roles_custom_without_vertical_chk` and an unknown vertical by the foreign key | `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R identity.roles.vertical_code is a nullable varchar(80)`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R roles_vertical_fk references platform.verticals(code)`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R roles_custom_without_vertical_chk exists`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R a custom role of an organization cannot carry a vertical`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R a role cannot name an unknown vertical` |
| Database layer: the seven system roles as D30 lists them | `manager` and `cashier` (neutral), `waiter`, `kitchen` and `bar` (`restaurant`), `sales_associate` and `stock_keeper` (`retail`), global, `active`, with the D30 English name and a description | `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R the seven D30 system roles exist with their exact code, name, vertical and status`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R every seeded role has a description` |
| Database layer: a system role is deleted | Refused, `23514 · identity_system_role_undeletable`, for each of the seven and for `organization_owner`; a custom role of an organization can still be deleted | `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R the system role ${code} cannot be deleted`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R a custom role of an organization can still be deleted`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R the eight system roles survive every delete attempt` |
| Database layer: a vertical role offered where no unit in the scope runs its vertical | Refused, `23514 · identity_invitation_role_vertical_inactive`: `waiter`, `kitchen` and `bar` where only retail is active, `sales_associate` and `stock_keeper` in a restaurant-only organization (organization-wide and at a business unit), a business unit scope on a unit without the vertical although another unit has it, a unit whose vertical row is `inactive`, and a vertical that is `deprecated` on the platform | `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R ${code} offered organization-wide where only retail is active is refused`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R ${code} offered organization-wide in a restaurant-only organization is refused`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R stock_keeper at a business unit scope of a restaurant-only organization is refused`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter at the retail-only unit is refused although another unit runs restaurant`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R sales_associate at the restaurant-only unit is refused`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter at a unit whose restaurant row is inactive is refused`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R a deprecated platform vertical is never offered` |
| Database layer: a vertical role offered where a unit in the scope runs its vertical, and a neutral role anywhere | Accepted: `waiter` in a restaurant organization and at the restaurant unit, organization-wide when one unit runs `restaurant`, `sales_associate` organization-wide when one unit runs `retail`; `manager` and `cashier` at every scope kind even with no active vertical | `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter offered organization-wide in a restaurant organization is accepted`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter at the restaurant unit is accepted`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter organization-wide is accepted when one unit runs restaurant`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R sales_associate organization-wide is accepted when one unit runs retail`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R ${code} is offerable at every scope kind even with no active vertical`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R a neutral role is accepted at an outlet with no effective business unit` |
| Database layer: scopes below a business unit | An outlet, a site, a brand or a legal entity covers the effective business units of its outlets today, an outlet with no effective business unit accepts only neutral roles, and an outlet scope reaches its restaurant child only with `include_descendants` | `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter at ${label}`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R a vertical role at an outlet with no effective business unit is refused`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter at a retail parent outlet without descendants is refused`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R waiter at a retail parent outlet with descendants is accepted through its restaurant child` |
| Database layer: an inactive, foreign or administrative role offered | `role_offerable_at_scope` is `false` for an `inactive` or `archived` role and for another organization's role; the invitation role check still refuses another organization's role and `organization_owner` | `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R an ${status} role is not offerable`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R a role of another organization is not offerable`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R a foreign custom role cannot be attached to an invitation`; `database/tests/contracts/roles-by-vertical-offering-contracts.ts › J1-R organization_owner is still refused as an invitation role` |
| Database layer: the vertical rule at acceptance and for the runtime role | Deactivating the vertical after the invitation exists makes `role_offerable_at_scope` answer `false` for the stored scope while the invitation stays; moving a stored scope runs the same rule; `ninaku_runtime` is bound by the same trigger and may still offer a neutral role | `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R deactivating the vertical after the invitation exists makes the acceptance re-check refuse`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R moving a stored scope runs the same rule`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R ninaku_runtime is refused a vertical role without the vertical`; `database/tests/contracts/roles-by-vertical-contracts.ts › J1-R ninaku_runtime may offer a neutral role` |
| Concurrency: an invitation scope racing the deactivation of the last unit running its vertical | A deactivation that commits first refuses the offer and leaves no scope; one that arrives after the offer started waits for it, and the acceptance re-check then refuses the stored scope | `database/tests/concurrency/roles-by-vertical-concurrency.ts › NINAKU_ROLES_BY_VERTICAL_CONCURRENCY_OK: an offer racing a committed deactivation is refused and leaves no scope`; `database/tests/concurrency/roles-by-vertical-concurrency.ts › NINAKU_ROLES_BY_VERTICAL_CONCURRENCY_OK: a deactivation waits for an in-flight offer and the acceptance re-check then refuses it` |
| Concurrency: an organization-wide offer and a deactivation locking the vertical rows in different physical orders | The offer takes its `FOR SHARE` locks in `bv.id` order, the same key as the deactivation, so with the heap order the reverse of the id order both sessions complete, serialised, with no `40P01`; 20 rounds | `database/tests/concurrency/roles-by-vertical-concurrency.ts › 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` |
| Concurrency: two invitations racing at the open-invitation limit | Exactly one is admitted; the other is refused with `identity_invitation_pending_limit` after waiting on the organization lock | `database/tests/concurrency/invitation-delivery-concurrency.ts › NINAKU_INVITATION_CONCURRENCY_OK: two enqueues at the pending limit admit exactly one` |
| Concurrency: two enqueues for one identifier within the hour | Exactly one message exists; the other is refused with `identity_invitation_identifier_rate_limit` | `database/tests/concurrency/invitation-delivery-concurrency.ts › NINAKU_INVITATION_CONCURRENCY_OK: two enqueues for one identifier admit exactly one per hour` |
| Concurrency: two organizations racing on one identifier at the daily cap | Neither message is lost; the first to commit is sent at once and the second waits for the soonest instant the cap allows | `database/tests/concurrency/invitation-delivery-concurrency.ts › NINAKU_INVITATION_CONCURRENCY_OK: two organizations racing on one identifier lose no message and keep a deterministic order` |

### O31 — Revoke an invitation

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: revoke an open invitation (J1 step 8) | `200 OK` with the invitation in state `revoked` (masked identifier, roles, no secret); `revoked_at` set; the person who got the link is told `409 · identity.invitation_closed` on the preview and on the acceptance, and the owner can invite the right person at once | `test/e2e/invitations-revoke.e2e-spec.ts` › `lets an owner who invited the wrong address revoke it: the person who got the link is told it is closed, and the right person can still be invited and join`; `src/modules/identity/application/invitations/revoke-invitation.use-case.spec.ts` › `locks the invitation row before it decides anything, then revokes, withdraws the message and audits, in that order`; `src/modules/identity/application/invitations/revoke-invitation.use-case.spec.ts` › `answers the invitation in state revoked, with the masked identifier and no secret` |
| The owner sees the revoked invitation | Not in the default list, listed with `state=revoked` | `test/e2e/invitations-revoke.e2e-spec.ts` › `shows the owner the revoked invitation in the list with its state, and nothing in the open list` |
| The pending message of the invitation | Dead-lettered as `invitation_revoked`, its sealed secret, nonce and key version wiped | `test/e2e/invitations-revoke.e2e-spec.ts` › `withdraws the pending message: it is dead-lettered as invitation_revoked and its sealed secret is wiped` |
| The message was already sent | Revocation still wins: the link stops working at once | `test/e2e/invitations-revoke.e2e-spec.ts` › `revokes even when the message was already sent: the link stops working at once` |
| Audit of the revocation | One `identity.invitation.revoked` record, reason `revoked_by_member`, `invitation.status` before `pending` and after `revoked`, the actor; never the secret, its hash or the full address | `test/e2e/invitations-revoke.e2e-spec.ts` › `leaves one audit record with before and after, the reason and the actor, and never the secret, its hash or the full address`; `src/modules/identity/application/invitations/revoke-invitation.use-case.spec.ts` › `audits the revocation with before and after, the reason revoked_by_member and no identifier or secret` |
| Revoke twice | `200` with the same body, nothing written, one audit record | `test/e2e/invitations-revoke.e2e-spec.ts` › `answers a second revocation of the same invitation with 200 and the same body, and writes no second audit record`; `src/modules/identity/application/invitations/revoke-invitation.use-case.spec.ts` › `answers an already revoked invitation as it is, writing and auditing nothing` |
| Revoke an accepted invitation | `409 · identity.invitation_closed`; the new member stays and nothing is audited | `test/e2e/invitations-revoke.e2e-spec.ts` › `refuses to revoke an invitation that was already accepted, with 409 identity.invitation_closed, and the new member stays`; `src/modules/identity/application/invitations/revoke-invitation.use-case.spec.ts` › `refuses an accepted invitation as closed and writes nothing` |
| Revoke an invitation that expired without being closed | `200`, revoked; the address is free for a fresh invitation | `test/e2e/invitations-revoke.e2e-spec.ts` › `revokes an invitation that expired without being closed, which frees the address for a fresh invitation` |
| Another tenant's or an unknown invitation id | The same `404 · identity.invitation_not_found` for both, nothing leaked and the foreign invitation untouched | `test/e2e/invitations-revoke.e2e-spec.ts` › `answers the same 404 for an unknown id and for the invitation of another organization, and never touches the foreign one`; `src/modules/identity/infrastructure/invitations/invitation-lifecycle.repository.integration-spec.ts` › `answers undefined for an id nobody issued and for an invitation of another organization` |
| An invitation id that is not a UUID | `400 · foundation.validation_failed` | `test/e2e/invitations-revoke.e2e-spec.ts` › `answers 400 for an identifier that is not a UUID` |
| No session, foreign organization, no header, a member without the permission | `401 · identity.session_required`; `403 · tenant.context_rejected`; `403 · identity.action_forbidden` for a cashier, a manager, a member with no role and an administrator scoped to one business unit, audited as denied with the invitation and its message untouched | `test/e2e/invitations-control-access.e2e-spec.ts` › `answers 401 identity.session_required without a session and changes nothing`; `test/e2e/invitations-control-access.e2e-spec.ts` › `answers 403 tenant.context_rejected without an organization header and for an organization the caller does not belong to`; `test/e2e/invitations-control-access.e2e-spec.ts` › `refuses a member whose role is %s with 403 identity.action_forbidden, audits the denial and leaves the invitation and its message alone`; `test/e2e/invitations-control-access.e2e-spec.ts` › `refuses a member whose administrative permission is scoped to one business unit`; `src/modules/identity/application/invitations/revoke-invitation.use-case.spec.ts` › `refuses a member without the permission before reading the invitation, and audits the denial` |
| Concurrency: revocation races an acceptance | Exactly one winner over HTTP: the revocation wins and the acceptance answers `409 · identity.invitation_closed`, or the acceptance wins, the membership exists and the revocation answers `409`; at the database the lock-first protocol is proven against `identity.accept_invitation` | `test/e2e/invitations-control-races.e2e-spec.ts` › `ends with exactly one winner when the invitee accepts at the moment the owner uses %s: either the invitation is closed for the invitee or it is already accepted for the owner, never both`; `database/tests/concurrency/invitation-reissue-concurrency.ts` › `NINAKU_INVITATION_REISSUE_CONCURRENCY_OK: a revoke or reissue that locks the row while an acceptance holds it reads the accepted state once it gets the lock`; `src/modules/identity/infrastructure/invitations/invitation-lifecycle.repository.integration-spec.ts` › `waits for the row lock of a transaction that is closing the invitation and then reads it closed`; `src/modules/identity/infrastructure/invitations/invitation-lifecycle.repository.integration-spec.ts` › `waits for whoever holds the lock the invite flow takes on the same address, so invite, revoke and reissue never interleave` |
| Concurrency: two administrators revoke the same invitation at once | Both are answered `200` with the same body, one audit record and one dead-lettered message | `test/e2e/invitations-control-races.e2e-spec.ts` › `lets two administrators revoke the same invitation at once: both are answered, the invitation changes once, one audit record and one dead-lettered message` |

### O32 — Accept an invitation

| Case | Expected answer | Proving test |
|---|---|---|
| Preview with a valid token, without a session (J1 step 3) | `200`, organization name, role names with their scope, expiry, masked identifier and `callerOwnsIdentifier: false`; nothing written | `test/e2e/invitations-accept.e2e-spec.ts` › `brings in an invitee without an identity: preview with no session says the identifier is not theirs yet, they sign up and verify it the ordinary way, then accept`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `shows an open invitation to a token holder and says whether the caller owns the identifier`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 a preview without a session shows the organization, the role, the scope and says the caller does not own the identifier` |
| Preview by the identity that owns the identifier verified, by another identity and by one that owns it unverified | The flag is `true` only for the owner; every other caller gets exactly what no session gets and never learns who owns the identifier | `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 the preview tells the identity that owns the identifier verified that it does`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 the preview tells any other identity the same as no session and never who owns the identifier`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 an identity that owns the identifier unverified is told it does not own it` |
| Preview writes nothing | No membership, role assignment, access scope, audit record, acceptance or outbox message; the function is `STABLE`, so the database itself refuses a write inside it | `test/e2e/invitations-accept-hardening.e2e-spec.ts` › `writes nothing when previewing: no membership, assignment, scope, audit record, acceptance or message`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `writes nothing`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 previewing wrote nothing: no membership, assignment, scope, audit record, acceptance or outbox message`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 the preview function is STABLE so the database itself refuses a write inside it` |
| Happy path: invitee whose identity already owns the identifier verified (J1 step 4) | `201`, membership + role assignment + access scope created, `accepted_at` set, and the new member establishes the tenant context for that organization and sees the roles in the session context | `test/e2e/invitations-accept.e2e-spec.ts` › `brings in an invitee who already has an identity: preview says so, acceptance answers 201 and the new member selects the organization and sees the roles`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `lets an invitee with no tenant context join, with the roles and scopes of the invitation and one audit record`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 a runtime session with no tenant context accepts and gets the membership, the organization and the role codes`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 the copied assignment names the inviter and the scope keeps the outlet and its descendants`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 the invitation records who accepted it and when` |
| Happy path: invitee without an identity signs up and confirms the invited identifier, then accepts (J1 step 5) | `201`; exactly one organization exists afterwards, the invited one | `test/e2e/invitations-accept.e2e-spec.ts` › `brings in an invitee without an identity: preview with no session says the identifier is not theirs yet, they sign up and verify it the ordinary way, then accept` |
| The audit record of an acceptance | One sealed tenant record `identity.invitation.accepted` in the same transaction, actor and effective identity the invitee, membership the new one, targets the invitation and the membership, after attributes for the status, the role codes and each scope; no token or hash in any of it | `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `writes a tenant audit record that carries neither the token nor its hash`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 one sealed tenant audit record is written for the acceptance with the invitee as actor and the new membership`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 the audit record targets the invitation and the new membership`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 no audit text carries the token or its hash`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 the audit attributes state the membership status, the role codes and the scope` |
| The function swaps the request settings to the new membership and restores them | `app.identity_id`, `app.actor_identity_id`, `app.membership_id` and `app.organization_id` of the calling session are the same after the function as before it | `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `restores the request settings of the connection after the function, in the same transaction`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 the request settings of the calling session are the same after the function as before it` |
| Unknown or guessed token | `404 · identity.invitation_not_found`, identical for every unknown token, on both routes, with or without a session | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `answers every unknown token alike with 404 identity.invitation_not_found, on both routes, with or without a session`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `refuses an unknown token as not found`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 an unknown token is refused as not found`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 an unknown token previews as nothing` |
| A token that is not 43 base64url characters, an unknown field, a missing token | `400 · foundation.validation_failed`; the shape of the token says nothing about any invitation | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `refuses a token that is not a 43 character base64url string with 400, and an unknown field` |
| Expired, revoked or already-accepted invitation | `409 · identity.invitation_closed`, the three not told apart, on the preview and on the acceptance, and nothing written | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `answers a closed invitation with 409 identity.invitation_closed whichever way it closed, and says nothing else`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `refuses a %s invitation as closed and writes nothing`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 a ${state} invitation is refused as closed and writes nothing`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 a ${state} invitation previews as closed without saying which and without any detail` |
| Organization suspended or archived after the invitation | `409 · identity.invitation_closed`; without a session `401 · identity.session_required` | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `answers 409 identity.invitation_closed when the organization is no longer active, and 401 without a session`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 an invitation of a suspended organization is refused as closed` |
| Signed-in identity does not own the identifier, or owns it unverified, revoked, blocked or not human | `403 · identity.invitation_recipient_mismatch`, never revealing whether another identity owns it; the invitation stays open and nothing is created | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `refuses a forwarded link with 403 and a body that is the same whoever owns the identifier, leaving the invitation open`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `refuses an identity that does not own the invited identifier verified, whoever else owns it`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 ${label} is refused as a recipient mismatch`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 the refusals leave the invitation open and create no membership` |
| Acceptance without a session, or with a session that is not live | `401 · identity.session_required` on the acceptance; the preview takes no session as nobody but refuses a presented session that is not live | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `answers 409 identity.invitation_closed when the organization is no longer active, and 401 without a session`; `test/e2e/invitations-accept-hardening.e2e-spec.ts` › `refuses a presented session that is not live on the preview instead of taking it for nobody`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 a session without an identity cannot accept` |
| Accepting identity already has a current membership | `409 · identity.invitee_already_member`; the invitation stays open | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `refuses an invitee that already belongs to the organization with 409 identity.invitee_already_member`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `refuses a caller that already belongs to the organization`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 an identity with a current membership is refused as already a member and the invitation stays open` |
| Accepting identity had a membership that ended | A new membership; the ended one stays as history | `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 an identity whose membership ended gets a new membership and keeps the old one as history` |
| Retry by the same identity after success | `201` with the same membership, nothing written again | `test/e2e/invitations-accept.e2e-spec.ts` › `answers 201 with the same membership when the same identity repeats the acceptance, and writes nothing again`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `answers the same membership when the same identity repeats and writes nothing again`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 the same identity accepting again gets the same membership and nothing is written again` |
| Adversarial: acceptance would exceed `identity.active_memberships` | `409 · identity.seat_limit_reached`; the invitation stays open and is accepted once a seat frees | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `refuses the acceptance over the seat limit with 409 identity.seat_limit_reached, keeps the invitation open and accepts it after a seat frees`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `refuses over the seat limit and accepts the same invitation once a seat frees`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 the acceptance over the seat limit is refused by the seat rule and the invitation stays open`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 after a seat frees the same invitation is accepted` |
| Concurrency: two invitees race for the last seat | One `201`, one `409 · identity.seat_limit_reached`; the other invitation stays open | `database/tests/concurrency/invitation-acceptance-concurrency.ts › NINAKU_INVITATION_ACCEPTANCE_CONCURRENCY_OK: the last seat raced by two invitees goes to exactly one and the other invitation stays open` |
| Concurrency: two sessions accept the same token | Exactly one membership and one audit record; the second answers the replay | `database/tests/concurrency/invitation-acceptance-concurrency.ts › NINAKU_INVITATION_ACCEPTANCE_CONCURRENCY_OK: two sessions accepting one token create exactly one membership and one audit record, and the second answers the replay` |
| Concurrency: a forwarded link raced against the real acceptance | The forwarded one finds the invitation closed and creates nothing | `database/tests/concurrency/invitation-acceptance-concurrency.ts › NINAKU_INVITATION_ACCEPTANCE_CONCURRENCY_OK: a forwarded link raced against the real acceptance finds the invitation closed` |
| Concurrency: a revocation racing an acceptance, either way round | The row lock serializes them: a revocation arriving during an acceptance is refused (terminal), an acceptance arriving during a revocation finds the invitation closed | `database/tests/concurrency/invitation-acceptance-concurrency.ts › NINAKU_INVITATION_ACCEPTANCE_CONCURRENCY_OK: a revocation arriving during an acceptance waits for it and is then refused`; `database/tests/concurrency/invitation-acceptance-concurrency.ts › NINAKU_INVITATION_ACCEPTANCE_CONCURRENCY_OK: an acceptance arriving during a revocation waits for it and finds the invitation closed` |
| Concurrency: two invitations of the same person accepted at once | One membership (`memberships_identity_organization_current_uq`); the second invitation stays open | `database/tests/concurrency/invitation-acceptance-concurrency.ts › NINAKU_INVITATION_ACCEPTANCE_CONCURRENCY_OK: two invitations of the same person accepted at once create one membership and leave the second invitation open` |
| An offered role was deactivated or made administrative after sending | `422 · identity.invitation_refused`; nothing written, the invitation stays open | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `refuses a role that became administrative after the invitation with 422 identity.invitation_refused and keeps the invitation open`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `refuses a role that was deactivated or became administrative after the invitation`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 a role ${label} after the invitation was sent is refused and nothing is written` |
| An offered role's vertical was deactivated (O11) in every business unit of its scope after sending | `422 · identity.invitation_role_vertical_inactive`; nothing written, the invitation stays open and is accepted once the vertical is active again | `test/e2e/invitations-accept-refusals.e2e-spec.ts` › `refuses a vertical role whose vertical was deactivated after the invitation with 422 identity.invitation_role_vertical_inactive and keeps it open`; `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `refuses a vertical role whose vertical was deactivated after the invitation`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 a vertical role whose vertical was deactivated after the invitation is refused and the invitation stays open`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 once the vertical is active again the same invitation is accepted` |
| Nothing of another organization is reached or changed | Accepting changes only the invited organization; without the function a runtime session with no tenant context cannot write a membership | `src/modules/identity/infrastructure/invitations/invitation-acceptance.repository.integration-spec.ts` › `is the only way in: a runtime session with no tenant context cannot write a membership itself`; `database/tests/contracts/invitation-acceptance-contracts.ts › J1-3 accepting changes only the invited organization and never another one` |
| The functions are narrow and privileged | Both take only the token hash, are `SECURITY DEFINER` with an empty search path; `ninaku_runtime` may execute them, the worker and `PUBLIC` may not | `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 both functions take only the token hash, are SECURITY DEFINER and pin an empty search path`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 ninaku_runtime may execute both, the worker and PUBLIC may not`; `database/tests/contracts/invitation-acceptance-guards-contracts.ts › J1-3 the worker role is refused` |
| More than 20 previews or acceptances in 10 minutes from one IP | `429 · rate_limit.exceeded` on the twenty-first of each route | `test/e2e/invitations-accept-hardening.e2e-spec.ts` › `answers the twenty-first $name from one address in ten minutes with 429 rate_limit.exceeded` |
| The token never leaves the server | Absent from every response body and header, every log line and every audit record of both routes, including the replay and the refusals | `test/e2e/invitations-accept-hardening.e2e-spec.ts` › `never carries the token or its hash in a response, a log line or an audit record, whatever the answer` |

### O33 — Reissue an invitation

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: reissue an expired invitation (J1 step 8) | `201`; the invitee accepts with the NEW link; the old link answers `409 · identity.invitation_closed` on the preview and on the acceptance | `test/e2e/invitations-reissue.e2e-spec.ts` › `lets the owner resend an expired invitation: the invitee joins with the NEW link while the old link answers closed`; `src/modules/identity/application/invitations/reissue-invitation.use-case.spec.ts` › `locks the old row first, then re-checks the offer, revokes, withdraws and audits the old invitation, inserts, delivers and audits the new one, in that order` |
| The answer | The new invitation, `pending`, same masked identifier and roles, an expiry counted from the reissue (7 days by default); no token or hash | `test/e2e/invitations-reissue.e2e-spec.ts` › `answers the new invitation pending, with the same roles, a seven day expiry counted from now and no secret`; `src/modules/identity/application/invitations/reissue-invitation.use-case.spec.ts` › `answers the new invitation, pending, with the masked identifier, the new expiry and no secret` |
| `expiresInDays` 1 to 30 | The days asked for are counted from the reissue; 0, 31, a fraction or an unknown field is `400 · foundation.validation_failed` and changes nothing | `test/e2e/invitations-reissue.e2e-spec.ts` › `counts the expiry from the reissue by the days the owner asks for`; `test/e2e/invitations-reissue.e2e-spec.ts` › `answers 400 for %s and changes nothing` |
| Roles and scopes are copied, the new row is linked | The new invitation offers the same roles at the same scopes and `replaces_invitation_id` names the old one, as the list shows | `test/e2e/invitations-reissue.e2e-spec.ts` › `keeps every role and scope of the old invitation on the new one and links it to the old one, as the owner sees in the list`; `src/modules/identity/application/invitations/reissue-invitation.use-case.spec.ts` › `creates the new invitation with the same organization, identifier, roles and scopes, a new secret, the expiry asked for and the link to the old one` |
| The outbox | The old message is dead-lettered and wiped; the new invitation has exactly one pending message with its own sealed secret | `test/e2e/invitations-reissue.e2e-spec.ts` › `moves the outbox: the old message is dead-lettered and wiped, the new one is pending with its own sealed secret`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 the old invitation is revoked, its pending message is dead-lettered as invitation_revoked and its ciphertext wiped`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 the replacement is open, linked to the old invitation, and has its own pending message with the ciphertext kept` |
| Audit of the reissue | `identity.invitation.revoked` on the old invitation (reason `reissued`, status before and after) and `identity.invitation.reissued` on the new one (`invitation.replaces`, `invitation.expires_at` before and after), never the secrets or the full address; the one record per target replaces the single record with two targets of the plan because the audit contract takes one target | `test/e2e/invitations-reissue.e2e-spec.ts` › `audits the old invitation as revoked and the new one as reissued with the previous and the new expiry, never the secrets or the full address`; `src/modules/identity/application/invitations/reissue-invitation.use-case.spec.ts` › `audits the old invitation as revoked for the reissue and the new one as reissued with the old and new expiry, never the secret or its hash`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 the audit vocabulary records a reissue as a tenant administration action with before and after attributes` |
| Reissue within one hour of the last message to the identifier | `429 · identity.invitation_rate_limited` with `Retry-After`; nothing revoked, the old link still works; after the cooldown the same request is `201` | `test/e2e/invitations-reissue-refusals.e2e-spec.ts` › `tells the owner who reissues right after sending to wait (429 with Retry-After) and changes nothing; after the cooldown the same request works`; `test/e2e/invitations-reissue-refusals.e2e-spec.ts` › `leaves the old invitation, its message, its link and the audit untouched when the cooldown refuses`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 a reissue within an hour of the last message is refused with the per-address cooldown and its hint`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 the refused reissue rolled everything back: the invitation is still open, its message still pending and no replacement exists`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 a reissue after the cooldown commits` |
| Retry of a reissue that already succeeded | `201` with the same replacement, no new invitation, message or audit record | `test/e2e/invitations-reissue-refusals.e2e-spec.ts` › `answers a retry of a reissue that already worked with 201 and the same replacement, without a second invitation or message`; `src/modules/identity/application/invitations/reissue-invitation.use-case.spec.ts` › `answers a retry with the replacement that is still open, writing, delivering and auditing nothing`; `src/modules/identity/application/invitations/reissue-invitation.use-case.spec.ts` › `refuses a retry whose replacement is already closed` |
| Reissue of an accepted or revoked invitation | `409 · identity.invitation_closed` | `test/e2e/invitations-reissue-refusals.e2e-spec.ts` › `refuses to reissue an invitation that was accepted, with 409 identity.invitation_closed`; `test/e2e/invitations-reissue-refusals.e2e-spec.ts` › `refuses to reissue an invitation the owner revoked: it is closed, the owner invites again instead` |
| Another tenant's or an unknown invitation id | The same `404 · identity.invitation_not_found` for both and the foreign invitation untouched | `test/e2e/invitations-reissue-refusals.e2e-spec.ts` › `answers the same 404 for an unknown id and for the invitation of another organization, and never touches the foreign one` |
| No session, foreign organization, no header, a member without the permission | `401`; `403 · tenant.context_rejected`; `403 · identity.action_forbidden` audited as denied against `identity.invitation.reissued`, nothing changed | `test/e2e/invitations-control-access.e2e-spec.ts` › `answers 401 identity.session_required without a session and changes nothing`; `test/e2e/invitations-control-access.e2e-spec.ts` › `answers 403 tenant.context_rejected without an organization header and for an organization the caller does not belong to`; `test/e2e/invitations-control-access.e2e-spec.ts` › `refuses a member whose role is %s with 403 identity.action_forbidden, audits the denial and leaves the invitation and its message alone`; `src/modules/identity/application/invitations/reissue-invitation.use-case.spec.ts` › `refuses a member without the permission before reading anything and audits the denial against the reissue action` |
| An offered role became inactive, retired or administrative, or its vertical was turned off | `422 · identity.invitation_refused`, `403 · identity.invitation_privilege_exceeded` (audited as denied) or `422 · identity.invitation_role_vertical_inactive`; nothing changes and the old invitation stays open and usable | `test/e2e/invitations-reissue-changes.e2e-spec.ts` › `refuses a waiter invitation after the owner turned the restaurant vertical off, with 422 identity.invitation_role_vertical_inactive, and the old invitation stays open and usable`; `test/e2e/invitations-reissue-changes.e2e-spec.ts` › `refuses a custom role that became %s with 422 identity.invitation_refused and changes nothing`; `test/e2e/invitations-reissue-changes.e2e-spec.ts` › `refuses a custom role that became administrative with 403 identity.invitation_privilege_exceeded, audits the denial and changes nothing else`; `src/modules/identity/application/invitations/reissue-invitation.use-case.refusals.spec.ts` › `refuses a role that became %s and leaves the old invitation open`; `src/modules/identity/application/invitations/reissue-invitation.use-case.refusals.spec.ts` › `refuses a role that became administrative, audits the denial and leaves the old invitation open`; `src/modules/identity/application/invitations/reissue-invitation.use-case.refusals.spec.ts` › `refuses a scope whose target is no longer active and leaves the old invitation open`; `src/modules/identity/application/invitations/reissue-invitation.use-case.refusals.spec.ts` › `rolls everything back when the database refuses the vertical of a role at the insert` |
| An invitation is replaced once | A unique partial index on `replaces_invitation_id` refuses a second replacement of the same invitation, even for another address; the invitation cannot be extended or relinked in place | `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 a unique partial index covers replaces_invitation_id, so finding the replacement of an invitation never scans the table`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 an invitation is replaced once: a second replacement of the same invitation is refused even for another address`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 the runtime cannot extend the expiry of an invitation: reissuing is revoke plus a new row`; `database/tests/contracts/invitation-reissue-contracts.ts` › `J1-5 the runtime cannot link an existing invitation to another one after the fact`; `src/modules/identity/infrastructure/invitations/invitation-failure.spec.ts` › `reads a second replacement of the same invitation as closed: somebody already replaced it` |
| Concurrency: two reissues of the same invitation race | Both answer `201` with the same replacement; one live invitation, one message, one `reissued` record; at the database the unique link lets exactly one replacement commit | `test/e2e/invitations-control-races.e2e-spec.ts` › `lets two administrators reissue the same invitation at once: both get 201 with the same replacement and only one live invitation and one message exist`; `database/tests/concurrency/invitation-reissue-concurrency.ts` › `NINAKU_INVITATION_REISSUE_CONCURRENCY_OK: two replacements of one invitation at once leave exactly one, the second is refused by the unique link` |
| Concurrency: reissue races an acceptance | Exactly one winner: the old invitation is closed for the invitee or the acceptance wins and the reissue answers `409` | `test/e2e/invitations-control-races.e2e-spec.ts` › `ends with exactly one winner when the invitee accepts at the moment the owner uses %s: either the invitation is closed for the invitee or it is already accepted for the owner, never both`; `database/tests/concurrency/invitation-reissue-concurrency.ts` › `NINAKU_INVITATION_REISSUE_CONCURRENCY_OK: an acceptance arriving during a reissue waits for it and finds the old invitation closed` |
| Journey: the owner hires a manager and a waiter, mistypes an address, revokes, reissues and the waiter joins | At each step the lists show the right states, replacements and seats | `test/e2e/invitation-control-journey.e2e-spec.ts` › `shows the owner, at each step, who is in, who is waiting, what was taken back and what replaced it` |

## Authorization contract and policy override

### O34 — Authorize an operation (internal contract)

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: caller has the permission at the right scope and the entitlement | `allowed`, from one call to the non-raising `platform.authorize_operation`; allowed exactly where the previous `platform.assert_operation_allowed` did not raise | `test/e2e/operation-authorization-journey.e2e-spec.ts › builds a plan with the structure and without the team, moves the organization onto it, and answers the owner, a cashier and each override with what each one is entitled to, until a private offering of Pionero gives the whole catalog back`; `src/modules/platform/infrastructure/operation-authorization/platform-operation-authorization.repository.integration-spec.ts › allows the owner of an organization on Pionero when the permission, the scope and the entitlement all hold, for both capabilities of the catalog`; `database/tests/contracts/operation-authorization-contracts.ts › platform.authorize_operation answers allowed exactly where the previous platform.assert_operation_allowed did not raise, over the whole matrix` |
| Caller has the permission but the plan doesn't entitle the capability | `capability_not_entitled`, `403 platform.capability_not_entitled` ("Your plan does not include this."), shown only to a caller who holds the permission; a staff deny beats the plan, a grant in force gives what the plan lacks, an expired grant is ignored; a caller without the permission gets the plain refusal of the consuming module, identical to any other plain denial | `src/modules/platform/infrastructure/operation-authorization/platform-operation-authorization.repository.integration-spec.ts › refuses the owner with capability_not_entitled when the plan does not include the capability, and a stranger with the plain permission_missing for the same request`; `src/modules/platform/infrastructure/operation-authorization/platform-operation-authorization.repository.integration-spec.ts › lets a staff deny beat the plan, ignores a grant that already expired and lets a grant in force give what the plan lacks`; `database/tests/contracts/operation-authorization-contracts.ts › a stranger, a suspended member, a member of another organization and a caller without tenant context are only ever answered permission_missing or tenant_context_missing, never anything about the plan, the place or the vertical` |
| Caller has permission and entitlement, but the target outlet (or an ancestor) is inactive | `outlet_not_operational` (`422 authorization.outlet_not_operational`) for an organization-wide member; `scope_unavailable` (`403 authorization.scope_unavailable`) for a member whose covering assignment is the closed place, before any plan question | `src/modules/platform/infrastructure/operation-authorization/platform-operation-authorization.repository.integration-spec.ts › answers scope_unavailable to the waiter whose outlet closed, outlet_not_operational to the organization-wide owner for that outlet, and permission_missing to a stranger`; `database/tests/contracts/operation-authorization-contracts.ts › each cause is answered in the order tenant context, permission, scope availability, entitlement, outlet, vertical` |
| No active tenant context | `tenant_context_missing`, answered before any other question; through a route the tenant guard already refuses with `403 tenant.context_rejected` | `src/modules/platform/infrastructure/operation-authorization/platform-operation-authorization.repository.integration-spec.ts › answers tenant_context_missing without an active tenant context and once the organization is suspended after the context was established`; `test/e2e/operation-authorization-journey.e2e-spec.ts › refuses every member of an organization the staff suspended at the door, before any plan question` |
| Permission-check half (`PermissionCheckContract`): the same permission through an organization-wide role and a business-unit role | Granted organization-wide; revoking only the organization-wide assignment stops the grant (no outlet id). The outlet path is out of scope: no outlets exist yet | `src/modules/identity/infrastructure/permission-check/permission-check.repository.access-paths.integration-spec.ts › grants an organization-wide check through the organization-wide role and stops when only that assignment is revoked` |
| Permission-check half: the same person in two organizations | A role held in one organization never grants in the other, in either direction, and a membership id from another organization is not granted | `src/modules/identity/infrastructure/permission-check/permission-check.repository.access-paths.integration-spec.ts › never carries a role held in one organization over to the same person in another organization` |
| Permission-check half: permission code spelled in another letter case | Not granted (codes are compared exactly) | `src/modules/identity/infrastructure/permission-check/permission-check.repository.access-paths.integration-spec.ts › does not grant the owner permission spelled %s` |
| Permission-check half: permission code with quotes, wildcards, backslash, whitespace or a dollar-quoted payload | Not granted and no error | `src/modules/identity/infrastructure/permission-check/permission-check.repository.access-paths.integration-spec.ts › does not grant, and does not fail, for a code with %s` |
| Permission-check half: permission code holding a NUL character, or exactly 120 characters | Not granted through the contract without reaching the database (a NUL used to raise a database error; fixed); 120 characters is delegated | `src/modules/identity/infrastructure/permission-check/permission-check.repository.access-paths.integration-spec.ts › answers not granted through the contract, without failing, for a code holding a NUL character`; `src/modules/identity/application/permission-check/check-permission.use-case.spec.ts › denies $label without reaching the repository`; `src/modules/identity/application/permission-check/check-permission.use-case.spec.ts › delegates a permission code of exactly the maximum length` |

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

| Case | Expected answer | Proving test |
|---|---|---|
| No override configured for an organization | Falls back to the platform default policy | — |
| Override exists and is well-formed | `VerificationPolicyPort`'s implementation returns the override, no use case changes | — |
| A previously-enrolled authenticator predates a later override change | `totpPolicyForMethod(stored)` reconstructs the original policy from the stored method, never locks out the enrolment | — |
| Override exists but is malformed/incomplete | Falls back to the platform default rather than failing every verification for that tenant | — |

***

## Cross-tenant and concurrency invariants

| Invariant | Expected answer | Proving test |
|---|---|---|
| Foreign brand or site referenced on outlet creation | Refused, indistinguishable from an unknown one: the activation trigger answers before the composite FK, which stays the second guard (O16) | `test/e2e/outlets-open-refusals.e2e-spec.ts › refuses a brand of another organization exactly as it refuses an unknown brand, with nothing stored or leaked`; `test/e2e/outlets-open-refusals.e2e-spec.ts › refuses a site of another organization exactly as it refuses an unknown site` |
| Foreign organization selected as tenant context | `403 · tenant.context_rejected` (O3) | `test/e2e/cross-tenant-context.e2e-spec.ts` › `answers 403 tenant.context_rejected on one route of each module`; `test/e2e/memberships-access.e2e-spec.ts` › `answers 403 tenant.context_rejected to a member of another organization and without a tenant header` |
| Direct cross-tenant `SELECT`/`UPDATE` as `ninaku_runtime` without a matching tenant context | Refused by RLS — the row is invisible or the write affects zero rows | `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › cannot see the row of another organization`; `src/modules/organization/infrastructure/organization-profile/organization-profile.repository.integration-spec.ts › leaves the row of another organization untouched, even when its id is named` (O5) |
| Retry of organization provisioning with the same idempotency key | Returns the same `organization_id`, never a duplicate (O1) | `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › answers the same organization to every concurrent replay of one key and creates exactly one` |
| Concurrent outlet↔legal-entity assignments for the same outlet | The outlet row lock serializes them and the GiST exclusion keeps exactly one active period even without the lock; history stays consistent (O20) | `test/e2e/outlets-assign-concurrency.e2e-spec.ts › lets exactly one of several parallel assignments of the same target on the same day create the row: one 201, the rest 200, one active row, one record`; `test/e2e/outlets-assign-concurrency.e2e-spec.ts › keeps exactly one active row covering the day when parallel assignments name two targets: every answer is a success and the superseded rows match the records`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › answers a conflict when a second active period overlaps the first, and stores nothing of the second` |
| Concurrent outlet↔business-unit assignments for the same outlet | The outlet row lock serializes them and the GiST exclusion keeps exactly one active period even without the lock; history stays consistent (O21) | `test/e2e/outlets-assign-concurrency.e2e-spec.ts › lets exactly one of several parallel assignments of the same target on the same day create the row: one 201, the rest 200, one active row, one record`; `test/e2e/outlets-assign-concurrency.e2e-spec.ts › keeps exactly one active row covering the day when parallel assignments name two targets: every answer is a success and the superseded rows match the records`; `src/modules/organization/infrastructure/outlets/outlet-assignment.repository.integration-spec.ts › answers a conflict when a second active period overlaps the first, and stores nothing of the second` |
| Concurrent outlet re-parenting on the same organization's tree | Serialized by the per-organization advisory lock; no transient cycle observed (O18) | `src/modules/organization/infrastructure/outlets/outlet-hierarchy.repository.integration-spec.ts › serialises two opposite moves: the second waits for the first, then is refused as a cycle, leaving one parent link`; `test/e2e/outlets-concurrency.e2e-spec.ts › settles two opposite moves at once into one outcome: one 200, one 422, and a tree without a cycle` |
| Last organization-wide administrator cannot be removed via role-assignment revocation | Refused at commit, `23514 · identity_last_administrator_required` (O28), answered `409 · identity.last_administrator_required` | `test/e2e/memberships-last-administrator.e2e-spec.ts` › `refuses the only administrator taking their own administrative role away, and the role stays` |
| Last organization-wide administrator cannot be removed via membership suspension/termination | Refused at commit, same constraint (O29), answered `409 · identity.last_administrator_required` | `test/e2e/memberships-last-administrator.e2e-spec.ts` › `refuses the only administrator, even themself, on %s with 409 identity.last_administrator_required and leaves no trace` |
| Last organization-wide administrator's scope cannot be narrowed away | Refused at commit, same constraint (O27), answered `409 · identity.last_administrator_required` | `test/e2e/role-assignments-scope.e2e-spec.ts` › `refuses the last administrator narrowing their own scope: 409 identity.last_administrator_required, the scope stays and nothing is audited as done` |
| A suspended organization that lost its administrator cannot be reactivated silently | Refused at commit, same constraint (O6); staff route answers `422 · platform.organization_command_refused` | `database/tests/contracts/administration-contracts.ts` › `AD14 cannot reactivate stranded tenant`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-04 reactivating a suspended organization that lost its last administrator is refused`; `test/e2e/platform-admin-organization-lifecycle.e2e-spec.ts` › `refuses to reactivate a suspended organization that lost its last administrator` |

***

## Gaps

Groups O1 to O18, O20 and O21 are done; the rest has no code yet. Grouped by family, each
line names the lowest layer that could prove it honestly once the
corresponding code exists:

* **O1 Provision organization** — done: DB-layer proof (Group 0) and the `POST /organizations` route with its integration and e2e proof; provisioning against the real vertical catalog (`hospitality`, `restaurant`, `retail`) is proven by the prepare contract and the e2e of slice J1-V (migration `0050`).
* **O2 Discover my organizations** — done (`GET /organizations`).
* **O3 Select organization / establish tenant context** — done (resolution contract, `GET /session/context`, `GET /subscription`, and the impersonation row proven at the database layer).
* **O4 Read current organization context** — done (`GET /session/context`).
* **O5 Update organization profile** — done (`PATCH /organization`, with its unit, integration and e2e proof).
* **O6 Suspend, reactivate, or archive organization** — done, staff-only: suspension and reactivation (SP17) and archival (`POST /platform-admin/organizations/{organizationId}/archival`) with their database, unit and e2e proof.
* **O7 Register legal entity** — done (`POST /legal-entities`, with its database, unit, integration and e2e proof).
* **O8 Update legal entity profile** — done (`PATCH /legal-entities/{legalEntityId}`, with its unit, integration and e2e proof).
* **O9 Register business unit** — done (`POST /business-units`, with its database, unit, integration and e2e proof). It stands alone: nothing in the table, its triggers or its policies requires a vertical activation, which is O11.
* **O10 Update business unit profile** — done (`PATCH /business-units/{businessUnitId}`, with its unit, integration and e2e proof).
* **O11 Activate or deactivate a vertical for a business unit** — done (`POST /business-units/{businessUnitId}/verticals/{verticalCode}/activation` and `.../deactivation`, with their database, unit, integration and e2e proof, including parallel deactivations of the last verticals and the row that deactivation leaves role assignments untouched).
* **O12 Register brand** — done (`POST /brands`, with its database, unit, integration and e2e proof).
* **O13 Update brand profile** — done (`PATCH /brands/{brandId}`, with its unit, integration and e2e proof).
* **O14 Register site** — done (`POST /sites`, with its database, unit, integration and e2e proof, including the timezone trigger and the coordinate checks).
* **O15 Update site profile** — done (`PATCH /sites/{siteId}`, with its unit, integration and e2e proof).
* **O16 Open outlet** — done (`POST /outlets`, with its database, unit, integration and e2e proof, including the brand, site and parent refusals that cannot tell an unknown element from a foreign one).
* **O17 Update outlet profile** — done (`PATCH /outlets/{outletId}`, with its unit, integration and e2e proof, including the API-layer refusal of the brand and the site and, since migration `0069`, the database refusal of any change of either).
* **O18 Re-parent an outlet** — done (`POST /outlets/{outletId}/re-parenting`, with its database concurrency, unit, integration and e2e proof, including the cycle, the inactive ancestor and the serialised moves).
* **O19 Register sales channel** — integration.
* **O20 Assign outlet to legal entity, effective from a date** — done (`POST /outlets/{outletId}/legal-entity-assignments` and `legalEntityId` in `POST /outlets`, with its database contract and two-connection concurrency (`database/tests/contracts` run by `db:test:prepare` and `database/tests/concurrency/outlet-assignment-concurrency.ts`), unit, integration and e2e proof, including the same-day replacement, the closed period that stays readable, the `409` conflict and the site timezone).
* **O21 Assign outlet to business unit, effective from a date** — done (`POST /outlets/{outletId}/business-unit-assignments` and `businessUnitId` in `POST /outlets`, with the same proof as O20, and the vertical roles at the scope of an outlet that it enables).
* **O22 Deactivate or reactivate a structural entity** — done (`POST /{sites,brands,legal-entities,business-units,outlets}/{id}/deactivation` and `/reactivation`, with their database contracts, two-connection concurrency, the old-versus-new differential of `identity.effective_permission`, unit, integration and e2e proof). The sales channel is proven at the database layer only (no route until O19). The `403 authorization.scope_unavailable` answer waits for the first route that checks a permission at an outlet.
* **O23 Read organization structure** — done (`GET /organization/structure` and `GET /organization`, with their ETag, `If-None-Match`, the `If-Match` of every structure and profile write, the database contract of `organization.structure_versions` and e2e proof, including the journey).
* **O24 Read effective assignment history** — done (`GET /outlets/{outletId}/legal-entity-assignments`, `GET /outlets/{outletId}/business-unit-assignments` and the paged `GET /sites/{siteId}/outlets`, with their e2e proof).
* **O25 List organization members** — done (`GET /memberships` and `GET /invitations`, with their unit, integration, database-contract and e2e proof, including the row that cannot occur); J1 slice J1-4.
* **O26 Assign a role to a member** — done (`POST /memberships/{membershipId}/role-assignments`, with its database contract and concurrency, unit, integration and e2e proof, including the D30 vertical rule at the use case and at the database, the administrator role refused and the journey); J1 slice J1-7.
* **O27 Change a role assignment's access scope** — done (`PATCH /role-assignments/{roleAssignmentId}/scope`, with its database contract and concurrency, unit, integration and e2e proof, including the last administrator and two administrators narrowing each other); J1 slice J1-7.
* **O28 Revoke a role assignment** — done (`DELETE /role-assignments/{roleAssignmentId}`, with its database contract and concurrency, unit, integration and e2e proof, including the last administrator and two administrators removing each other); J1 slice J1-6.
* **O29 Suspend or end a membership** — done (`POST /memberships/{membershipId}/suspension`, `/reactivation` and `/termination`, with their database contract and concurrency, unit, integration and e2e proof, including the seat limit on reactivation, the member shut out at once and the journey); J1 slice J1-6.
* **O30 Invite a person to the organization** — done (DB contract, unit, integration, e2e, worker integration including two-organization delivery, runtime system-role refusal, offerable roles read and invite route); J1 slices J1-1a, J1-1c, J1-1b, J1-R, J1-2a, J1-W and J1-2.
* **O31 Revoke an invitation** — done (`POST /invitations/{invitationId}/revocation`, with its unit, integration, database-concurrency and e2e proof, including the revocation racing an acceptance and two administrators revoking at once); J1 slice J1-5.
* **O32 Accept an invitation** — done (`POST /invitation-previews` and `POST /invitation-acceptances`, with their database, concurrency, unit, integration and e2e proof, including both invitee paths and the new member selecting the organization).
* **O33 Reissue an invitation** — done (`POST /invitations/{invitationId}/reissue`, with its database contract and concurrency, unit, integration and e2e proof, including the cooldown, the retry, the offer that stopped being valid and two reissues racing); J1 slice J1-5.
* **O34 Authorize an operation (internal contract)** — done (lot E): `OperationAuthorizationContract` over the non-raising `platform.authorize_operation`, with the old-versus-new differential of 2,520 cases against the previous `platform.assert_operation_allowed`, unit, integration and an HTTP journey through the five staff capability routes and a test-only consuming route. No Organization command is gated (D15); the first operational module gates its commands and exposes the refusals over HTTP.
* **O35 Override verification-code / TOTP policy for an organization** — unit once `VerificationPolicyPort`'s new implementation exists; not schedulable yet, no table exists.
* **Cross-tenant and concurrency invariants (all 11 rows)** — done.
