Skip to content

Organization Case Matrix ​

One row per behavioral case for every use case named in 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. A case with no proving test answers — and is listed under 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 cases35
Per-case scenario rows586
Per-case scenario rows proven579 (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 invariants11 (11 proven)
Gaps7

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).

CaseExpected answerProving 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 assignmentdatabase/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 activePrepare 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 insertNo organization, membership, subscription or provisioning-request row survives a failed provisioning attemptdatabase/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 payloadSame organization_id returned, no duplicate rowdatabase/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 payloadRefused — fingerprint mismatchdatabase/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 privateSame 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 marketRefused exactly as before (422 · organization.provisioning_refused), nothing createddatabase/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 paused409 · idempotency.key_reused (the replay mismatch), never the stored organizationdatabase/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 ownerThe 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 MarketRefused, never silently substituteddatabase/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 activeRefused before any insertdatabase/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 MarketRefused before any insertdatabase/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 identityRefuseddatabase/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 directlyRefused — permission denied for function provision_organization; must keep passing unchangeddatabase/tests/prepare/prepare-database-contracts.ts › organizationRuntimeCannotProvisionOrganizations
Concurrency: the same key and body raced by several requests at onceEvery request answers 201 with the same organization_id; exactly one organization, membership, subscription and provisioning-request row exists for that keytest/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 bodyOne 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 organizationtest/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 caseExactly one 201, the rest 409 · organization.code_taken; one organization row and one provisioning-request rowtest/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 identitiesOne 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 trialtest/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 listCode 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 nothingtest/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 object400 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 objecttest/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 id422 · organization.provisioning_refused with the matching detail, nothing created, no SQL or constraint name in the bodytest/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 lettersThe 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 datatest/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 currencyUnicode, 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 createdtest/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 code409 · organization.code_taken carries no organization name, id, identity or SQL text of the holdertest/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 replay400 for each malformed key; an upper-case replay of a key is the same key and answers the same organizationtest/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 ownerGeneric 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 call401 · identity.session_required, nothing provisionedtest/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 changesOne 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 requireddatabase/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 bodyNo second record: the replay returns before anything is writtendatabase/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 backNo 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 nonedatabase/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 recordOnly the new organization's tenant context sees it; another tenant reads nonedatabase/tests/prepare/organization-provisioning-audit-contracts.ts › theRecordIsReadableOnlyInsideTheNewOrganization

O2 — Discover my organizations ​

CaseExpected answerProving test
Happy path: identity with two active memberships lists both organizations200 OK, two rowstest/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 memberships200 OK, empty pagetest/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 membershipThat organization is excluded, never presentedsrc/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 organizationNever presented as selectablesrc/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
PaginationKeyset cursor, no OFFSET/LIMITsrc/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 limit400 · foundation.validation_failed on the limit field for every refused value; limit=100 accepted; 50 items and a next page when absenttest/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 cursors400: 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 500test/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 allForward 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 100test/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 charactersReturned verbatim, in join order, query intacttest/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 earlier401 · identity.session_required on both reads, no organization id in the body; reactivation does not revive the old tokentest/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 ​

CaseExpected answerProving test
Happy path: caller with an active membership sends X-Organization-Id for that organizationTransaction proceeds with RLS scoped to that organizationsrc/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 there403 · tenant.context_rejectedsrc/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 ended403 · tenant.context_rejectedsrc/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 active403 · tenant.context_rejectedsrc/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 context403 · tenant.context_rejectedsrc/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 tripleHTTP answers 403 · tenant.context_rejected through assert_tenant_context; no HTTP header carries impersonation, so the row is proven at the database layerdatabase/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 failsUppercase 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_rejectedsrc/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 ​

CaseExpected answerProving test
Happy path: caller with an active membership sends a valid X-Organization-Id200 OK with the resolved organization, role(s), and permission summarytest/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 deprecatedOnly active roles and the active permissions they grant are reported, sorted by codesrc/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 theirsRoles and permissions are those of the selected organization only, and empty for an unselected or foreign onetest/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 exists200 OK, selectedOrganizationId: null, hasAvailableOrganizations: truetest/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 organization200 OK, selectedOrganizationId: null — never confirms the foreign organization existstest/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 endedhasAvailableOrganizations: falsetest/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 metacharactersUppercase selects the organization and answers its lowercase id; the others select nothing and answer 200, never 500; a colleague never inherits another member's rolestest/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 assignedselectedOrganizationId: null, no roles and no permissionstest/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 linkedPermission listed once; roles and permissions sorted by code; retired roles and deprecated permissions disappear at oncetest/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 ​

CaseExpected answerProving test
Happy path: caller with org-wide organization.account.manage updates name/locale/currency200 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 fieldtest/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 routeRefused at the API layer: 400 foundation.validation_failed naming code as an unrecognised field; nothing changes and no record is writtentest/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 changedtest/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_currencyRefused 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 existstest/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 nametest/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 stored200 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 outOnly the fields whose value really differs are written and audited; an unchanged field never appears in the recordtest/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 updateSealed, with the actor identity, the membership, the organization and the organization as its targettest/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 repeatedlyEvery attempt leaves its own denied record, and no record ever carries the values it tried to writetest/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 type400 foundation.validation_failed with the field named; nothing changes and no record exists; name and locale limits are those of provisioningsrc/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 charactersStored, returned and audited verbatimtest/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected, identical for a foreign and an unknown organization; nothing changes and no record existstest/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 codeSafe generic detail in every refusaltest/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 organizationSerialised 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 oncetest/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 organizationInvisible 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 keyNever disguised as a refusal; it surfaces as itself and the row is unchangedsrc/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 startsAnswered as a rejected tenant context, nothing writtensrc/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.

CaseExpected answerProving test
Suspend an active organization that keeps its administrator (staff only, SP17)204 No Content, status suspended, the reason kept in the audit recordtest/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 activetest/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 suspendedRefused at commit, 23514 · identity_last_administrator_requireddatabase/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 wasdatabase/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 targettest/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 organization204 No Content, status archivedtest/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_provisionedtest/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 archived422 · 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 bodytest/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 keysExactly one 204, every other request 422, one success record and one denied record per refused attempttest/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 exist404 · platform.organization_not_found, no audit recordtest/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 field400 · foundation.validation_failed, status unchanged, no audit recordtest/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 archivedtest/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 issued403 · platform.elevation_required, status unchanged, no audit recordtest/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 staff403 · platform.staff_required, the same body for a real and an unknown organization, no denied recordtest/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 session401 · identity.session_required, status unchangedtest/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses a request without a session with 401
No Idempotency-Key400, status unchanged, no audit recordtest/e2e/platform-admin-organization-archival.e2e-spec.ts › refuses a request without an Idempotency-Key and changes nothing
Same key, same reason, retried204 again, one audit recordtest/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 reason409 · idempotency.key_reused, the original reason stays in the audit recordtest/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 organization422 · platform.organization_command_refused, status stays archivedtest/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 organization422 · platform.organization_command_refused, status stays archivedtest/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 subscriptionThe payment renews the subscription but the organization stays archivedtest/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 oneThe 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 blockeddatabase/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 reasonplatform.organization.archived is high risk, scope platform, and refuses a record without a reason text or reason codedatabase/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 organizationNothing selected, no roles, no permissions, for the owner and for a membertest/e2e/platform-admin-organization-archival-members.e2e-spec.ts › selects nothing in the session context, for the owner and for a member
Members: GET /organizationsThe archived organization is no longer listedtest/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 id403 · tenant.context_rejected, no datatest/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 organization403 · tenant.context_rejected, the profile unchangedtest/e2e/platform-admin-organization-archival-members.e2e-spec.ts › refuses a profile change with tenant.context_rejected and keeps the profile as it was
CaseExpected answerProving test
Happy path: a member with org-wide organization.account.manage registers a legal entity201 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 entriestest/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 nullStored as null, answered as null and left out of the audit recordtest/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 caseTrimmed, 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 400test/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 whitespace409 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 ittest/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 organizationAllowed, in any case; uniqueness is per organizationtest/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_country422 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 freetest/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 storedtest/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 permission201; the record names that member and membership: the permission decides, not the role nametest/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 repeatedlyEvery attempt leaves its own denied record, and no record ever carries the values it tried to writetest/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 type400 foundation.validation_failed; nothing stored and no record exists; code is at most 50 characters and names at most 250test/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 field400 naming the field as unrecognised; the organization always comes from the tenant context and the status is always activetest/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 charactersStored, returned and audited verbatimtest/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing stored and no record existstest/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 codeExactly one 201, the rest 409; one row and one record; holds for variants in case and whitespacetest/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 codeSafe generic detail in every refusaltest/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 organizationNever disguised as a structure failure; it surfaces as itself, nothing is stored, and a write into another organization is refusedsrc/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 grantsThe 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 holddatabase/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
CaseExpected answerProving test
Happy path: rename legal_name and/or trade_name200 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 firsttest/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 one200; 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 fieldRefused at the API layer: 400 foundation.validation_failed naming the field as unrecognised; nothing changes and no record is writtentest/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 exist404 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 existstest/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 UUID400 naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercasetest/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 stored200 OK with the current legal entity; nothing is written, updated_at does not move and no record existstest/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 changeOnly the fields whose value really differs are audited; an unchanged field never appears in the recordtest/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 existstest/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 O8One function asks organization.account.manage of the tenant membership and records exactly one denied record for the asked action and target when it is missingsrc/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 permission200; the record names that member and membershiptest/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 type400 foundation.validation_failed; nothing changes and no record exists; null is accepted only for the trade namesrc/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing changes and no record existstest/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 entitySerialised 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 oncetest/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 updatingNever 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 onlysrc/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 ​

CaseExpected answerProving 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 verticaltest/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 verticalCodes422 organization.vertical_unavailable; no unit, no vertical and no record is storedtest/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 code400 foundation.validation_failedtest/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 organizationBoth 201; they decide their assortment one after the other, so exactly one starts at none and the other at alltest/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 unit201 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 entriestest/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 recordedtest/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 whitespace409 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 ittest/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 unit409 organization.structure_code_taken in any case or with whitespace; the provisioned business unit stays as it was and no record existstest/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 organizationAllowed, in any case; uniqueness is per organizationtest/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 field400 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 storedtest/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 permission201; the record names that member and membership: the permission decides, not the role nametest/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 repeatedlyEvery attempt leaves its own denied record, and no record ever carries the values it tried to writetest/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 type400 foundation.validation_failed; nothing stored and no record exists; code is at most 80 characters and the name at most 200test/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 charactersStored, returned and audited verbatim; code and name are trimmed firsttest/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 organizationEach answers 201 and leaves its own row and its own record; the identifier is new every timetest/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing stored and no record existstest/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 codeExactly one 201, the rest 409; one row and one record; holds for variants in case and whitespace; parallel registrations of different codes all succeedtest/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 codeSafe generic detail in every refusaltest/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 organizationNever disguised as a structure failure; it surfaces as itself, nothing is stored, and a write into another organization is refusedsrc/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 grantsThe 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 ​

CaseExpected answerProving test
Happy path: rename200 OK with the business unit; one organization.business_unit.updated record carries the old name as before and the new one as aftertest/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 changedThe other business units of the organization stay as registered and no record names themtest/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 createdRenamed like any othertest/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 fieldRefused at the API layer: 400 foundation.validation_failed naming the field as unrecognised; nothing changes and no record is writtentest/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 exist404 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 existstest/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 UUID400 naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercasetest/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 again200 OK with the current business unit; nothing is written, updated_at does not move and no record exists; a repeated change records oncetest/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 caseA real change: written, answered and auditedtest/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 existstest/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 type400 foundation.validation_failed; nothing changes and no record exists; the name is at most 200 characterstest/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 whitespaceTrimmed, stored and returned verbatimtest/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing changes and no record existstest/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 unitSerialised by FOR UPDATE: all answer 200, every record's before is the after of the record before it, identical renames write oncetest/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 updatingNever 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 sizesrc/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.

CaseExpected answerProving test
Happy path: activate a vertical for one business unit that has none of it201 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 organizationtest/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 row200 OK, status = 'active', disabled_at cleared and enabled_at unchanged (the first activation); one record with inactive as the before valuetest/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 mixBoth states coexist independently: activating or deactivating on one unit never touches a siblingtest/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 deprecated422 · 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 repositorytest/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 ittest/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 another200 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 membershiptest/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 know200, status = 'inactive'; no row is created, nothing is written and no record existstest/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 deprecatedStill works: 200, the row inactivetest/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 archived422 · organization.vertical_unavailable; the row stays as it was and no record existstest/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 existstest/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 permission201 then 200; the records name that member and membership: the permission decides, not the role nametest/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 unit404 · 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 recordtest/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 units409 · 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 remainingtest/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 onceExactly 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 changedtest/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 UUID400 · foundation.validation_failed; nothing changes and no record exists; the identifier is read in lowercasetest/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 exist401 · identity.session_required without a session; otherwise 403 · tenant.context_rejected; nothing changes and no record existstest/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 untouchedAn open invitation row is byte-for-byte the same after an activation and a deactivation; refusing it at acceptance belongs to O32test/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 untouchedAssignments kept as labelstest/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 codeSafe generic detail in every refusaltest/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 organizationNever 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 writesrc/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 grantsThe 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 triggerdatabase/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 ​

CaseExpected answerProving test
Happy path: a member with org-wide organization.account.manage registers a brand201 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 entriestest/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 charactersAccepted 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 recordedtest/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 whitespace409 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 ittest/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 organizationAllowed, in any case; uniqueness is per organizationtest/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 field400 naming the field as unrecognised; the organization always comes from the tenant context and the status is always activetest/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 storedtest/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 permission201; the record names that member and membership: the permission decides, not the role nametest/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 repeatedlyEvery attempt leaves its own denied record, and no record ever carries the values it tried to writetest/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 type400 foundation.validation_failed; nothing stored and no record exists; code is at most 50 characters and the name at most 200test/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 charactersStored, returned and audited verbatim; code and name are trimmed firsttest/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 organizationEach answers 201 and leaves its own row and its own record; the identifier is new every timetest/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing stored and no record existstest/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 codeExactly one 201, the rest 409; one row and one record; holds for variants in case and whitespace; parallel registrations of different codes all succeedtest/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 codeSafe generic detail in every refusaltest/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 organizationNever disguised as a structure failure; it surfaces as itself, nothing is stored, and a write into another organization is refusedsrc/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 grantsThe 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 ​

CaseExpected answerProving test
Happy path: rename200 OK with the brand; one organization.brand.updated record carries the old name as before and the new one as aftertest/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 changedThe other brands of the organization stay as registered and no record names themtest/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 fieldRefused at the API layer: 400 foundation.validation_failed naming the field as unrecognised; nothing changes and no record is writtentest/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 exist404 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 existstest/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 UUID400 naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercasetest/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 again200 OK with the current brand; nothing is written, updated_at does not move and no record exists; a repeated change records oncetest/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 caseA real change: written, answered and auditedtest/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 existstest/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 type400 foundation.validation_failed; nothing changes and no record exists; the name is at most 200 characterstest/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 whitespaceTrimmed, stored and returned verbatimtest/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing changes and no record existstest/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 brandSerialised by FOR UPDATE: all answer 200, every record's before is the after of the record before it, identical renames write oncetest/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 updatingNever 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 sizesrc/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 ​

CaseExpected answerProving test
Happy path201 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 settest/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 recordNever written: the record says that an address field or a coordinate was set, not what it holdstest/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 null201; the optionals are stored and answered as null and left out of the recordtest/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 spenttest/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 timezoneAccepted, 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 storedtest/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 notRefused 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 failuretest/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 numberRefused 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 guardtest/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 arithmeticThe 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 decimalstest/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 countryThe 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 recordedtest/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 whitespace409 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 ittest/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 organizationAllowed, in any case; uniqueness is per organizationtest/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 field400 naming the field as unrecognised; the organization always comes from the tenant context and the status is always activetest/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 storedtest/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 permission201; the record names that member and membership: the permission decides, not the role nametest/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 repeatedlyEvery attempt leaves its own denied record, and no record ever carries the values it tried to writetest/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 type400 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 100test/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 whitespaceTrimmed, stored, returned and (for the name) audited verbatimtest/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 organizationEach answers 201 and leaves its own row and its own record; the identifier is new every timetest/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 itPermission, then insert, then the success record, then commit; a repository failure rolls back everything and writes no recordsrc/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing stored and no record existstest/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 codeExactly one 201, the rest 409; one row and one record; holds for variants in case and whitespace; parallel registrations of different codes all succeedtest/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 catalogThe conflict, the country refusal and the timezone refusal carry only their public code and detailtest/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 grantsThe 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 updatedatabase/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 firstsrc/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 ​

CaseExpected answerProving test
Happy path: rename200 OK with the site; one organization.site.updated record carries the old name as before and the new one as aftertest/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 changedThe other sites of the organization stay as registered and no record names themtest/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 timezone200; a real timezone is stored and audited by nametest/e2e/sites-update.e2e-spec.ts › changes the timezone to another real one and audits its names
Correct the address200; the five address fields change; the record carries set as before and after for each, never a valuetest/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 nullEach 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 clearedtest/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 themLatitude 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 beforetest/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 shapeRefused at the API layer: 400 foundation.validation_failed; nothing changes and no record is written; the database checks stay as the second guardtest/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 zoneThe 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 existstest/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 fieldRefused at the API layer: 400 foundation.validation_failed naming the field as unrecognised; nothing changes and no record is writtentest/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 exist404 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 existstest/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 UUID400 naming the path field before any lookup; an uppercase UUID is accepted, acts on the same row and is recorded in lowercasetest/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 null200 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 auditedtest/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 existstest/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 type400 foundation.validation_failed; nothing changes and no record existstest/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 whitespaceTrimmed, stored and returned verbatimtest/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 organization401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing changes and no record existstest/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 siteSerialised 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 mixedtest/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 updatingNever 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 ​

CaseExpected answerProving test
Happy path: outlet tied to one brand and one site in the same organization201 Created with the outlet, stored active; one organization.outlet.opened record carries the created values as after; a top-level outlet has no parent attributetest/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 parent201; the parent is stored, answered and recorded as an after attributetest/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 unknown422 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 refusaltest/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 unknown422 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 unknown422 organization.structure_refused, one and the same answer for the three (outlets_parent_site_organization_fk); nothing stored or recordedtest/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 databasetest/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 parent422 organization.structure_refused (organization_outlet_parent_active); nothing storedtest/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 spaces409 organization.structure_code_taken; nothing stored and no record; the same code is free in another organizationtest/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 codesExactly one 201 and the rest 409 for the same code; all 201 for different codes; one record eachtest/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 type400 foundation.validation_failed; nothing stored and no recordtest/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 identifiersTrimmed, stored and returned verbatim; identifiers are accepted in upper case and answered in lower casetest/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 allowedtest/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 in401 identity.session_required without a session; otherwise 403 tenant.context_rejected; nothing changestest/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 openingNever disguised as a taken code or a refusal, never auditedsrc/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 permissionsNo SQL, table, constraint or permission text in any refusaltest/e2e/outlets-access.e2e-spec.ts › never reveals SQL, tables, constraints or permissions in a refusal
Two organizations side by sideEach outlet exists only in its own organizationtest/e2e/outlets-open.e2e-spec.ts › opens outlets of two organizations side by side, each only in its own organization

O17 — Update outlet profile ​

CaseExpected answerProving test
Happy path: rename200 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 aftertest/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 routeRefused at the API layer: 400 foundation.validation_failed; nothing changes and no record existstest/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 again200 OK with the current outlet; nothing is written and no record exists; parallel identical renames write oncetest/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 exist404 organization.structure_not_found, identical for both; nothing changes and no record existstest/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 case400 before any lookup; an upper-case identifier is acceptedtest/e2e/outlets-update.e2e-spec.ts › answers 400 for an identifier that is not a UUID
Caller lacks the permission403 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 existstest/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 whitespaceTrimmed, stored and returned verbatimtest/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 outletSerialised by FOR UPDATE: all 200, every record's before is the after of the record before ittest/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 renamingNever disguised as a taken code and never auditedsrc/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 ​

CaseExpected answerProving test
Happy path: move an outlet under a new, valid parent on the same site200 OK with the new parent, stored; one organization.outlet.reparented record carries the parent as before and aftertest/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 site200 with a null parent; its own children stay under ittest/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 outlet200 with the current outlet; nothing is written and no record existstest/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 answertest/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 active422 organization.structure_refused (23514 · organization_outlet_parent_active); the ancestor is set inactive with SQL because deactivation (O22) has no route yettest/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 unknown422 organization.structure_refused, one and the same answer for the three; nothing changestest/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 treeSerialised 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 acyclicsrc/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 exist404 organization.structure_not_found, identical for both; nothing changestest/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 identifiers400 foundation.validation_failed; nothing changes; upper-case identifiers are acceptedtest/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 permission403 organization.action_forbidden, one denied record against the outlettest/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 changesName, code, brand, site and status keep their valuessrc/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 movingPropagates untouched, rolled back and unauditedsrc/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, detachAt 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 verticaltest/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 dateThe 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 activetest/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 itNo 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 40P01database/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 ​

CaseExpected answerProving test
Happy path201 Created—
Unknown channel_kindRefused by the check constraint—
Duplicate codeRefused—

Effective assignments ​

CaseExpected answerProving 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 sitetest/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 laterThe 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 datetest/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 date200 with the same body; nothing written, no recordtest/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_from400 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 guardtest/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 outlet422 organization.structure_refused; nothing written, no recordtest/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 date409 organization.assignment_conflict; nothing written, no recordtest/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 unknown404 organization.structure_not_found, one and the same answer for the four; the composite FK is the second guardtest/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 active422 organization.structure_refused, a detail of its own for the target and for the outlet; organization_outlet_assignment_legal_entity_active is the second guardtest/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 outletThe 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 23P01test/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 placeRefused by protect_outlet_legal_entity_assignment; closing the period stays allowedsrc/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 writtentest/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 to401 identity.session_required or 403 tenant.context_rejected; nothing writtentest/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 identifier400 foundation.validation_failed; nothing writtentest/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 storedtest/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 row200 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 aftertest/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 stored200 with the stored end; nothing written or recorded, as before D5test/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 row409 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 rowBoth 200; one row whose end is the after value of the last record, whose before is the after value of the firsttest/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 oneThe current row is closed at d1; from d2 on nothing is assigned: a gap is never filledtest/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 ​

CaseExpected answerProving 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 sitetest/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 laterThe 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 datetest/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 date200 with the same body; nothing written, no recordtest/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_from400 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 guardtest/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 outlet422 organization.structure_refused; nothing written, no recordtest/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 date409 organization.assignment_conflict; nothing written, no recordtest/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 unknown404 organization.structure_not_found, one and the same answer for the four; the composite FK is the second guardtest/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 active422 organization.structure_refused, a detail of its own for the target and for the outlet; organization_outlet_assignment_business_unit_active is the second guardtest/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 outletThe 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 23P01test/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 placeRefused by protect_outlet_business_unit_assignment; closing the period stays allowedsrc/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 writtentest/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 to401 identity.session_required or 403 tenant.context_rejected; nothing writtentest/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 identifier400 foundation.validation_failed; nothing writtentest/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 storedtest/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 row200 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 aftertest/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 stored200 with the stored end; nothing written or recorded, as before D5test/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 row409 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 rowBoth 200; one row whose end is the after value of the last record, whose before is the after value of the firsttest/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 oneThe current row is closed at d1; from d2 on nothing is assigned: a gap is never filledtest/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 ​

CaseExpected answerProving test
Happy path: deactivate an active business unit/legal entity/brand/site/outlet/sales channel with no active children200 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 layertest/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 active200 OK, status = 'active', one organization.<kind>.reactivated record; the owner reopens a closed branch top-downtest/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 active422 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 locktest/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 scopeThe 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 verticalstest/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 deleteNot 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 rowRefused 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 outletDecided (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 layerssrc/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 ​

CaseExpected answerProving test
Happy path: composite read returns business units, legal entities, brands, sites, and the outlet tree with each outlet's current assignments200 OK, one composed payloadtest/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 assignmentSurfaced as null, not hidden or defaultedtest/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 structureInvisible via RLStest/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=allThe default leaves out every inactive or archived element, status=all lists them; each answers its own ETagtest/e2e/organization-structure.e2e-spec.ts › hides an inactive site and its outlets by default and shows them with status=all
More than 500 outletsThe first 500 with meta.truncated true; the paged site route lists the resttest/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-Match304 without a body while nothing changed; every structure or profile write moves the version once per statement, so the previous ETag answers 200test/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 it412 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 winstest/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 recordedtest/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 writeThe 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 datetest/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-MatchAccepted while the version is unchanged, whatever its assignment segment; a dated tag of an older version is 412test/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 zonesThe 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 sitessrc/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 ​

CaseExpected answerProving test
Happy path: outlet with three historical assignments and one currentAll four rows returned, ordered by valid_fromtest/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 interpolatedtest/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 outletInvisible via RLStest/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 siteNewest first, no gap or duplicate while outlets are opened; a tampered, foreign-site or foreign-route cursor is 400 with the existing cursor codestest/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 rowsThe latest 500, still in ascending order, with meta.truncated true; at most 500 answer every row with meta.truncated falsetest/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 ​

CaseExpected answerProving 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 roletest/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 descendantsThe 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 identifiersAn 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,suspendedA suspended member shows suspendedAt, an ended one endedAt; an ended member is not in the default listsrc/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 listmeta.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 undefinedtest/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 listedGET /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 destinationtest/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,expiredopen, 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 listtest/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 invitationThe 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 outboxsrc/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 bodytest/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 to401 · identity.session_required; 403 · tenant.context_rejectedtest/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 invitationsNever 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 filter400 · 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 pagesKeyset 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 pagetest/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 parametersA 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 rowtest/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 bodyCache-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 onetest/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 scopesCannot exist — the deferred trigger requires at least one; this case cannot occur, not a defensive branchdatabase/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.

CaseExpected answerProving test
Happy path: give an active, non-administrative role at a valid scope to an active membership201 Created; the member is listed with the role and keeps working in the organizationtest/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 roleOne success record with the role code, the membership and the scope as after attributes, actor the callertest/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 scope200 OK with the same assignment, no write, no second audit recordtest/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 scope409 · 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 target422 · identity.role_assignment_refused, all answered aliketest/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 id400 · foundation.validation_failed, nothing writtentest/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 verticaltest/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 administration403 · identity.role_assignment_privilege_exceeded, audited as denied; nobody becomes administrator over HTTPtest/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 ended409 · identity.membership_not_active, nothing writtentest/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 organization404 · identity.membership_not_found, indistinguishabletest/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_missingtest/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 context401 · identity.session_required; 403 · tenant.context_rejectedtest/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 sideA role given in one is not seen in the other and the other owner reaches nothing of ittest/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 endRevoke, give, list and context agree at every steptest/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.

CaseExpected answerProving test
Happy path: narrow a non-last-administrator's scope200 OK, same assignment, old scope row deleted, new one insertedtest/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 back200 OKtest/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 changeOne success record with the scope before and aftertest/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 again200 OK with the same assignment, no write, no audit recordtest/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 outlet409 · identity.last_administrator_required, the scope stays, nothing audited as donetest/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 existsAllowedsrc/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 siteThe member loses organization-wide administration at their very next requesttest/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 organization403 · identity.role_assignment_privilege_exceeded, audited as deniedsrc/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 momentOne wins, the other is refused, one organization-wide administrator remainstest/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 deletedThe old scope is kept: the delete never survives without the insertsrc/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 kepttest/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 role422 · identity.role_assignment_refused, aliketest/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 organization404 · identity.role_assignment_not_found, indistinguishabletest/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 member409 · identity.membership_not_activetest/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 id400 · foundation.validation_failedtest/e2e/role-assignments-scope-refusals.e2e-spec.ts › answers %s with 400
No session, foreign organization, a member without the permission401 · identity.session_required, 403 · tenant.context_rejected, 403 · identity.action_forbidden audited as deniedtest/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.

CaseExpected answerProving test
Happy path: revoke a non-last-administrator's role assignment200 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 requesttest/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 assignmentRefused, 23514 · identity_last_administrator_required answered 409 · identity.last_administrator_required; nothing is deleted and no audit record survivestest/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 existsAllowedtest/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 organizationSerialized 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 remainssrc/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 roleAllowed: the member stays active with no role and can do nothingtest/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 toOne tenant audit record identity.role_assignment.revoked with the role code and the membership id as before valuestest/e2e/memberships-role-revocation.e2e-spec.ts › audits the revocation with the role and the member it belonged to
Repeated revocation404 · identity.role_assignment_not_found, because the assignment is gone; no second audit recordtest/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 permission403 · identity.action_forbidden, audited as denied with the reason permission_missing; nothing changestest/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 organizationThe same 404 · identity.role_assignment_not_found for both; the other organization is untouchedtest/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.

CaseExpected answerProving test
Happy path: suspend a non-last-administrator's membership200 OK, status = 'suspended', the member still listed with that status, the seat freed, one audit record with the status before and aftertest/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 membershipRefused, 409 · identity.last_administrator_required, including the caller themself; nothing changes and no audit record survivestest/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 administratorWith the second one suspended the owner is the last again; reactivating the second lets the owner step downtest/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 momentOne 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 againA new identity.memberships row with fresh roles, never a reopened old one; the ended one stays in the historytest/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 cashier200 OK, status = 'ended', listed with status=ended, the seat freed, one audit recordtest/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 once403 · tenant.context_rejected, identical to a stranger's; their account and sessions staytest/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 invitationAn acceptance refused with 409 · identity.seat_limit_reached is accepted once the owner ends or suspends a membertest/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 limit409 · identity.seat_limit_reached, the member stays suspended and nothing is audited, until another seat freestest/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 member200 OK, status = 'active', the access returns at once and the seat is taken againtest/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 command200 OK with the same state, nothing written, no second audit recordtest/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 final409 · identity.membership_ended on suspension or reactivation of an ended membershiptest/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 permission403 · identity.action_forbidden, audited as denied; nothing changestest/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 organizationThe same 404 · identity.membership_not_found for both; the other organization is untouchedtest/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 stepOfferable roles, invitations in every state, members by status and the seats agree after each step, from the first invitation to the retail roles appearingtest/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 ​

CaseExpected answerProving 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 bodytest/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 exercisedsrc/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 holdsThe same 201, content type and body shape (compared with the invitation id, expiry and masked address replaced); nothing reveals which. Not compared: response timetest/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 organization409 · 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 invites403 · 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 messagetest/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 roletest/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_ownertest/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 restauranttest/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 outlettest/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 kindssrc/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 bodysrc/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 200test/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 listedtest/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 parametertest/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 rowtest/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 vertical201: a neutral role organization-wide, at a business unit and at an outlet with its descendants, and sales_associate organization-wide in a retail organizationtest/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 twiceExactly 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 themdatabase/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 roleRefused (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 directly42501 (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_opendatabase/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 offered403 · identity.invitation_privilege_exceeded, audited as deniedtest/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 unknown422 · identity.invitation_refused, with the same detail for each cause; nothing is writtentest/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 configuration409 · identity.invitation_already_opentest/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 raced200 with the same invitation; one row and one message existtest/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 identifierRevoked 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_invitations409 · identity.invitation_pending_limit_reachedtest/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 hour429 · 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 numbertest/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 integertest/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 daystest/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 writtentest/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 readtest/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 clearsrc/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 invitationsrc/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 untouchedsrc/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 onesrc/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 organizations201; the message is deferred (available_at later), never refusedOver 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 AA's message is not superseded; both are delivereddatabase/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 repeatedlyWithdrawn and dead-lettered messages do not count toward the cross-organization cap; A's message is never deferred past one hour before its expires_atdatabase/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 leasedAfter 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 lettersListed 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:3000src/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 messageIt 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 issrc/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 serverAbsent 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 challengeAdmitted 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 invitationdatabase/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 invitedB'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 addressEach leaves the other alone: the newer reset supersedes only the older reset, and an invitation message leaves a pending reset in placedatabase/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 pendingThe failed message returns to pending with its failure class; it is not dead-lettered as superseded_by_resenddatabase/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 messageRead from the invitation row (normalized_value and identifier_type); identity.enqueue_invitation_message takes no destination argument, so the API can never redirect a messagedatabase/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 notRefused 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 behinddatabase/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_invitationsBeyond 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 itdatabase/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 hoursThe 30th is accepted and the 31st is refused with 23514 · identity_invitation_org_rate_limit; invitations older than 24 hours leave the windowdatabase/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 hourA 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 helddatabase/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 organizationsEnqueued, 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 countdatabase/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 messageReturned 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 expireddatabase/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 usedThe 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 alonedatabase/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 invitationDead-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 withdrawdatabase/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 messageRefused, 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 denialdatabase/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 hoursThe fourth is deferred by 24 hours from the oldest of the three; pending, leased and published messages all countdatabase/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 historyAn invitation older than the 31-day maximum validity does not take a slotdatabase/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 idAppended after the unchanged columns for an invitation message, NULL for a challenge message; the worker's own column list still reads the extended claimdatabase/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 invitationDead-lettered (invitation_revoked, invitation_accepted, invitation_expired) with the ciphertext wiped, never leased; the message of an open invitation is still leaseddatabase/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 invitationDead-lettered as invitation_accepted with the ciphertext wipeddatabase/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 expiryA retryable error never returns the message to pending, so no later claim can offer itdatabase/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 replacesreplaces_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 accountRefused, 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 admitteddatabase/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 columnidentity.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 keydatabase/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 themmanager and cashier (neutral), waiter, kitchen and bar (restaurant), sales_associate and stock_keeper (retail), global, active, with the D30 English name and a descriptiondatabase/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 deletedRefused, 23514 · identity_system_role_undeletable, for each of the seven and for organization_owner; a custom role of an organization can still be deleteddatabase/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 verticalRefused, 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 platformdatabase/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 anywhereAccepted: 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 verticaldatabase/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 unitAn 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_descendantsdatabase/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 offeredrole_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_ownerdatabase/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 roleDeactivating 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 roledatabase/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 verticalA 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 scopedatabase/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 ordersThe 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 roundsdatabase/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 limitExactly one is admitted; the other is refused with identity_invitation_pending_limit after waiting on the organization lockdatabase/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 hourExactly one message exists; the other is refused with identity_invitation_identifier_rate_limitdatabase/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 capNeither message is lost; the first to commit is sent at once and the second waits for the soonest instant the cap allowsdatabase/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 ​

CaseExpected answerProving 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 oncetest/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 invitationNot in the default list, listed with state=revokedtest/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 invitationDead-lettered as invitation_revoked, its sealed secret, nonce and key version wipedtest/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 sentRevocation still wins: the link stops working at oncetest/e2e/invitations-revoke.e2e-spec.ts › revokes even when the message was already sent: the link stops working at once
Audit of the revocationOne 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 addresstest/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 twice200 with the same body, nothing written, one audit recordtest/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 invitation409 · identity.invitation_closed; the new member stays and nothing is auditedtest/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 closed200, revoked; the address is free for a fresh invitationtest/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 idThe same 404 · identity.invitation_not_found for both, nothing leaked and the foreign invitation untouchedtest/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 UUID400 · foundation.validation_failedtest/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 permission401 · 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 untouchedtest/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 acceptanceExactly 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_invitationtest/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 onceBoth are answered 200 with the same body, one audit record and one dead-lettered messagetest/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 ​

CaseExpected answerProving 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 writtentest/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 unverifiedThe flag is true only for the owner; every other caller gets exactly what no session gets and never learns who owns the identifierdatabase/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 nothingNo membership, role assignment, access scope, audit record, acceptance or outbox message; the function is STABLE, so the database itself refuses a write inside ittest/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 contexttest/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 onetest/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 acceptanceOne 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 itsrc/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 themapp.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 itsrc/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 token404 · identity.invitation_not_found, identical for every unknown token, on both routes, with or without a sessiontest/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 token400 · foundation.validation_failed; the shape of the token says nothing about any invitationtest/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 invitation409 · identity.invitation_closed, the three not told apart, on the preview and on the acceptance, and nothing writtentest/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 invitation409 · identity.invitation_closed; without a session 401 · identity.session_requiredtest/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 human403 · identity.invitation_recipient_mismatch, never revealing whether another identity owns it; the invitation stays open and nothing is createdtest/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 live401 · identity.session_required on the acceptance; the preview takes no session as nobody but refuses a presented session that is not livetest/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 membership409 · identity.invitee_already_member; the invitation stays opentest/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 endedA new membership; the ended one stays as historydatabase/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 success201 with the same membership, nothing written againtest/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_memberships409 · identity.seat_limit_reached; the invitation stays open and is accepted once a seat freestest/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 seatOne 201, one 409 · identity.seat_limit_reached; the other invitation stays opendatabase/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 tokenExactly one membership and one audit record; the second answers the replaydatabase/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 acceptanceThe forwarded one finds the invitation closed and creates nothingdatabase/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 roundThe row lock serializes them: a revocation arriving during an acceptance is refused (terminal), an acceptance arriving during a revocation finds the invitation closeddatabase/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 onceOne membership (memberships_identity_organization_current_uq); the second invitation stays opendatabase/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 sending422 · identity.invitation_refused; nothing written, the invitation stays opentest/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 sending422 · identity.invitation_role_vertical_inactive; nothing written, the invitation stays open and is accepted once the vertical is active againtest/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 changedAccepting changes only the invited organization; without the function a runtime session with no tenant context cannot write a membershipsrc/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 privilegedBoth take only the token hash, are SECURITY DEFINER with an empty search path; ninaku_runtime may execute them, the worker and PUBLIC may notdatabase/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 IP429 · rate_limit.exceeded on the twenty-first of each routetest/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 serverAbsent from every response body and header, every log line and every audit record of both routes, including the replay and the refusalstest/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 ​

CaseExpected answerProving 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 acceptancetest/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 answerThe new invitation, pending, same masked identifier and roles, an expiry counted from the reissue (7 days by default); no token or hashtest/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 30The days asked for are counted from the reissue; 0, 31, a fraction or an unknown field is 400 · foundation.validation_failed and changes nothingtest/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 linkedThe new invitation offers the same roles at the same scopes and replaces_invitation_id names the old one, as the list showstest/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 outboxThe old message is dead-lettered and wiped; the new invitation has exactly one pending message with its own sealed secrettest/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 reissueidentity.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 targettest/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 identifier429 · identity.invitation_rate_limited with Retry-After; nothing revoked, the old link still works; after the cooldown the same request is 201test/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 succeeded201 with the same replacement, no new invitation, message or audit recordtest/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 invitation409 · identity.invitation_closedtest/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 idThe same 404 · identity.invitation_not_found for both and the foreign invitation untouchedtest/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 permission401; 403 · tenant.context_rejected; 403 · identity.action_forbidden audited as denied against identity.invitation.reissued, nothing changedtest/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 off422 · 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 usabletest/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 onceA 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 placedatabase/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 raceBoth answer 201 with the same replacement; one live invitation, one message, one reissued record; at the database the unique link lets exactly one replacement committest/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 acceptanceExactly one winner: the old invitation is closed for the invitee or the acceptance wins and the reissue answers 409test/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 joinsAt each step the lists show the right states, replacements and seatstest/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) ​

CaseExpected answerProving test
Happy path: caller has the permission at the right scope and the entitlementallowed, from one call to the non-raising platform.authorize_operation; allowed exactly where the previous platform.assert_operation_allowed did not raisetest/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 capabilitycapability_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 denialsrc/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 inactiveoutlet_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 questionsrc/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 contexttenant_context_missing, answered before any other question; through a route the tenant guard already refuses with 403 tenant.context_rejectedsrc/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 roleGranted organization-wide; revoking only the organization-wide assignment stops the grant (no outlet id). The outlet path is out of scope: no outlets exist yetsrc/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 organizationsA role held in one organization never grants in the other, in either direction, and a membership id from another organization is not grantedsrc/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 caseNot 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 payloadNot granted and no errorsrc/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 charactersNot granted through the contract without reaching the database (a NUL used to raise a database error; fixed); 120 characters is delegatedsrc/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 ​

CaseExpected answerProving test
No override configured for an organizationFalls back to the platform default policy—
Override exists and is well-formedVerificationPolicyPort's implementation returns the override, no use case changes—
A previously-enrolled authenticator predates a later override changetotpPolicyForMethod(stored) reconstructs the original policy from the stored method, never locks out the enrolment—
Override exists but is malformed/incompleteFalls back to the platform default rather than failing every verification for that tenant—

Cross-tenant and concurrency invariants ​

InvariantExpected answerProving test
Foreign brand or site referenced on outlet creationRefused, 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 context403 · 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 contextRefused by RLS — the row is invisible or the write affects zero rowssrc/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 keyReturns 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 outletThe 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 outletThe 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 treeSerialized 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 revocationRefused at commit, 23514 · identity_last_administrator_required (O28), answered 409 · identity.last_administrator_requiredtest/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/terminationRefused at commit, same constraint (O29), answered 409 · identity.last_administrator_requiredtest/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 awayRefused at commit, same constraint (O27), answered 409 · identity.last_administrator_requiredtest/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 silentlyRefused at commit, same constraint (O6); staff route answers 422 · platform.organization_command_refuseddatabase/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.

Application Foundation in progress. Tracked in issue #13.