Appearance
Organization Use Cases
Organization is the tenant boundary. This catalog starts from what a signed-in identity, an invited person, or another module tries to do, and derives the minimal API from that — never from the tables. Every case names the invariant it protects, its main path, and its adversarial/edge cases. The proving-test matrix for these cases lives in ORGANIZATION_CASE_MATRIX.md; nothing here is implemented yet, so read this catalog as frozen intent, not shipped behavior.
Ground truth for every case below comes from database/canonical/0020_organization.sql, 0040_identity.sql, 0050_security_rls.sql, 0060_platform.sql, 0400_cross_domain_contracts.sql, database/access/runtime_grants.sql, docs/reference/ARCHITECTURE.md, DOMAIN_MAP.md, DECISIONS.md (D04, D18, D21, D22), docs/contracts/MODULE_CONTRACTS.md, API_AND_SCHEMA_DISCIPLINE.md, APP_SHELL_AND_ONBOARDING.md, and the current src/foundation/tenancy/**, src/foundation/transactions/**, src/modules/identity/application/session-context/** code. Corrections made against the reviewer-proposed list are called out inline where they matter.
Decision-row legend. HTTP? — does this need a public route, or is it internal-only. Command/Read? — a state-changing command, a read model, or both. Merged with — the case it was folded into, if any. Module — the NestJS module that owns the write. DB objects — the canonical schema objects touched. Missing grants — whether ninaku_runtime can reach those objects today (see database/access/runtime_grants.sql); "none" means the grants already exist.
1. Provisioning, discovery, tenant context
O1 — Provision organization
Actor: any registered, signed-in identity (self-service; no invitation needed). Intent: turn a signed-in identity into the owner/administrator of a brand-new organization, in one atomic, retry-safe step. Preconditions: caller holds a live session; caller supplies a code, name, country, currency, locale, an initial business unit, at least one vertical, and a plan offering they chose for that country's Market. Invariants protected: D21 (an admitted organization always keeps at least one active administrator) — the function seeds it directly by inserting the owner's role_assignment + organization-wide access_scope in the same transaction that admits the organization; idempotency (platform.organization_provisioning_requests.idempotency_key is globally unique — a retried request with the same key returns the same organization_id, never a duplicate); no partial garbage (organization, business unit, vertical activation, subscription, membership, role assignment and access scope are inserted in one transaction — a failure after INSERT organizations and before the owner's access scope exists rolls all of it back); the API role never gets migrator credentials — platform.provision_organization is SECURITY DEFINER, REVOKE ALL ... FROM PUBLIC, and stays callable only by ninaku_migrator, never ninaku_runtime directly. Shipped in Group 0 (revision 28): the real entrypoint ninaku_runtime calls is the narrower platform.provision_organization_for_caller, which drops the caller-suppliable owner-identity/owner-role parameters entirely and resolves them itself (see Platform's SUBSCRIPTION_PLANS_USE_CASES.md SP3), then calls platform.provision_organization internally. Main path:
- Caller submits code, name, country, currency, locale, initial business unit, verticals, the
plan_offering_idthey chose, and an idempotency key. platform.provision_organization_for_callervalidates the country, currency and locale againstreference.*, that the country has an active Market, and that the chosen Offering belongs to that Market and is active and available now.platform.reserve_organization_provisioning_requestreserves the key (or returns the already-completedresult if this is a retry).platform.provision_organizationvalidates the owner is an active human identity and the chosen role is a global, active role grantingorganization.account.manage, then inserts organization, business unit, vertical activations, subscription, membership, role assignment, and org-wide access scope, thenplatform.record_organization_provisionedwrites one tenant Audit Recordorganization.provisionedin the same transaction: outcomesuccess, authorizationallowed, the caller as actor and the owner membership as membership, the new organization as its own organization and as theorganization.organizationtarget, and the created values as after-only changes (organization.code,organization.name,organization.default_locale,organization.default_currencyandplatform.plan_offering_id, the Offering the Subscription starts on). The function swaps the four request settings (app.identity_id,app.actor_identity_id,app.membership_id,app.organization_id) to the new owner for the capture, so the existingaudit.create_record,audit.add_record_target,audit.add_record_attributeandaudit.seal_recordvalidate it unchanged, and restores them afterwards.platform.complete_organization_provisioning_requestmarks the requestcompletedwith the neworganization_id. Adversarial / edge cases:
- Retried request with the same idempotency key but a different payload — fingerprint mismatch is rejected, not silently merged.
- Retried request with the same idempotency key and the same payload after a prior success — returns the same
organization_id, no new row. The stored result is answered before the Market-active, Offering-available and Offering-public checks (migration0052), so a client that lost the response still gets the id after staff paused the offering, closed the market or made the offering private; a NEW key is still refused by those checks, and the same key with a different payload is rejected as a mismatch even then. - The creation is audited in the tenant store (
audit.records), not inaudit.platform_records: the organization is the tenant and the owner is its member from that transaction on, so its members read the record of how it began. A refused or rolled-back provisioning leaves no record (the record is written inside the same transaction), and a replay of a completed key returns before anything is written, so it never writes a second record. The record carries no request id, trace id or remote address: the provisioning transaction does not receive the request context, soorigin_kindissystem. - Chosen plan offering belongs to another country's Market — refused, never silently substituted.
- Chosen plan offering is not
active, or outside its availability window — refused before any insert. - Unknown country/currency/locale, or a country with no active Market — refused before any insert.
- Owner identity is
blocked/disabled/servicekind — refused, with no organization, membership, subscription or provisioning-request row left behind. - Currency different from the chosen Offering's currency — accepted on purpose, not an oversight. The organization's
default_currencyis an initial business preference (CURRENCY_AND_LANGUAGE.md: it does not replace a document's currency; D11 keeps currencies independent) and O5 lets the owner change it freely afterwards, so equality with the Offering would only hold until the first profile edit. What Ninaku bills isplan_offerings.currency_code, which the subscription keeps unchanged. The currency is part of the idempotency fingerprint: the same key with another currency is a different request (409 idempotency.key_reused). Open product question for the owner: whether a future rule should require equality. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /organizations | Command | — | Organization owns the route; calls platform.provision_organization_for_caller over the ordinary ninaku_runtime pool | organization.organizations, organization.business_units, platform.business_unit_verticals, platform.subscriptions, identity.memberships, identity.role_assignments, identity.access_scopes, platform.organization_provisioning_requests | None — shipped in Group 0. ninaku_runtime has EXECUTE on platform.provision_organization_for_caller; platform.provision_organization itself stays barred. |
O2 — Discover my organizations
Actor: any signed-in identity, before any tenant context is selected. Intent: see which organizations I can enter. Preconditions: live session; no X-Organization-Id required. Invariants protected: an identity only discovers organizations tied to its own active memberships — identity.memberships carries a memberships_select_policy that allows identity_id = current_identity_id() regardless of tenant context, so this read never needs has_active_tenant_context() to be true. Main path:
- Caller requests the list with a live session, no organization header.
- Query joins
identity.memberships(statusactive) →organization.organizations(statusactive) foridentity_id = caller. - Response returns id, name, code, and the caller's own membership status per organization — paginated by keyset, not offset/limit (per
API_AND_SCHEMA_DISCIPLINE.md). Adversarial / edge cases:
- Identity with zero memberships — empty page, not an error.
- Identity with a
suspended/endedmembership — that organization is excluded (or shown with an explicitmembershipStatus, decision left to the API design step; either way it is never presented as selectable). - Identity with a membership in a
suspended/archivedorganization — organization appears only if the API explicitly documents that state; never presented as selectable. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — GET /organizations | Read model | — | Identity (owns memberships) reads across into organization.organizations; exposed as an Organization-facing read contract per D18's "read contract for compositions" mode | identity.memberships, organization.organizations | None — ninaku_runtime already has SELECT on identity.memberships and organization.organizations; memberships_select_policy scopes the read to the caller |
O3 — Select organization / establish tenant context
Actor: any signed-in identity with at least one active membership. Intent: make one organization the tenant boundary for the requests that follow. Preconditions: live session; caller presents an organization identifier the coordinator's proposed contract carries as the X-Organization-Id header on every tenant-scoped request (see Derived API for the alternative). Invariants protected: RLS tenant isolation — TenantContextRunnerService.apply sets the five app.* GUCs and immediately calls identity.assert_tenant_context(), which fails closed (42501) unless identity.has_active_tenant_context() finds a live, active membership row matching identity_id, organization_id, and membership_id all at once, with the organization itself active. Main path:
- Caller sends
X-Organization-Id: <uuid>alongside a live session on any tenant-scoped request. - A guard resolves the caller's active
membership_idfor that(identity_id, organization_id)pair. TenantTransactionRunnerService.run(context, work)opens the transaction and callsTenantContextRunnerService.apply, setting the GUCs and asserting tenant context beforeworkruns.- On success, the request proceeds with RLS scoped to that organization. Adversarial / edge cases:
- Known UUID of a foreign organization, no membership there — the guard never finds a matching membership; refused before any GUC is even set, answers
403 · tenant.context_rejected. - Membership exists but is
suspended/ended—has_active_tenant_context()requiresm.status = 'active'; refused the same way. - Organization exists but its own status is not
active— refused the same way (o.status = 'active'is part of the sameEXISTS). - Header omitted entirely on a route that requires tenant context — refused by the same guard (no organization to resolve).
- Impersonation: the impersonation session must itself be live (
ended_at IS NULL, inside itsstarted_at/expires_atwindow) and must name the same actor/organization/membership triple, or the same refusal applies. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
No dedicated route: TenantContextResolver (foundation tenancy port, implemented by Identity) resolves the caller's active membership for an organization, and the foundation TenantContextGuard applies it on every module's tenant-scoped routes | Command (mutates session GUCs for the transaction) | Shares its check with O4's organization-availability read | Identity implements the port; TenantContextRunnerService (foundation tenancy) applies the context | identity.memberships, organization.organizations, identity.has_active_tenant_context(), identity.assert_tenant_context() | None. ninaku_runtime can read identity.memberships and identity.current_membership_id(); it needs no grant on identity.current_impersonation_session_id() because has_active_tenant_context() is SECURITY DEFINER |
O4 — Read current organization context (GET /session/context)
Actor: any signed-in identity. Intent: know, in one call, who I am, whether I have a tenant selected, and whether I have any organization to select. Preconditions: live session. Invariants protected: the two-request screen-load budget in API_AND_SCHEMA_DISCIPLINE.md (GET /session/context + one workspace call); never inferring organization state from the client — the server always recomputes hasAvailableOrganizations and the resolved organization from the database, never from a client-supplied flag. Main path:
- Caller requests
GET /session/context, optionally withX-Organization-Id. GetSessionContextUseCaseresolves identity context (as it does today) and, additionally, resolves organization context: if the header names an organization the caller has an active membership in, return that organization plus the caller's active role(s)/permission summary; if absent or invalid, returnselectedOrganizationId: null.- Independently of the header, resolve
hasAvailableOrganizationsfrom a realEXISTSoveridentity.memberships(today this is a hardcodedfalse— see Adversarial/edge cases). Adversarial / edge cases:
- Today's implementation is a stub.
src/modules/identity/application/session-context/get-session-context.use-case.tsalways returnsorganizationContext: { selectedOrganizationId: null, hasAvailableOrganizations: false }regardless of the caller — this case is entirely unimplemented past the identity-only response and must be built, not adjusted. X-Organization-Idnames a foreign organization — the response must answer as if the header were absent (selectedOrganizationId: null), not leak whether that organization exists; the request is not refused outright here because this route also serves callers who have no context yet.- Identity has memberships but all are
suspended/ended—hasAvailableOrganizationsisfalse. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — GET /session/context (existing route, extended) | Read model | Shares its guard with O3 | Identity (owns the route and the identity half); reads Organization's organization.organizations and identity.memberships for the org half | identity.memberships, organization.organizations | Same as O2/O3 |
2. Organization profile and lifecycle
O5 — Update organization profile
Actor: a member with organization.account.manage at organization-wide scope. Intent: change the organization's name, default locale, or default currency. Preconditions: tenant context established (O3); caller's effective permission check passes for organization.account.manage at org-wide scope. Invariants protected: code is the organization's stable identifier (unique on lower(code)) and is never edited here — renaming the business is a name change, not a code change; default_locale/default_currency must resolve to real reference.locales/reference.currencies rows (FK enforced). Main path:
- Caller submits new
name/default_locale/default_currency. - Command validates the permission, then
UPDATE organization.organizations SET name = ..., default_locale = ..., default_currency = .... Adversarial / edge cases:
- Caller attempts to change
code— rejected at the API layer; the column has no dedicated "rename code" case in this catalog (flagged as an open product question in the final report, not assumed here). - Caller lacks org-wide scope (e.g., only has a business-unit-scoped role) — refused by the permission check, not by RLS (RLS only isolates the tenant, not the action).
- Unknown
default_locale/default_currencycode — refused by the FK, not a silent fallback. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — PATCH /organization | Command | — | Organization | organization.organizations | None: runtime_grants.sql already grants ninaku_runtime SELECT, UPDATE on organization.organizations, and organizations_update_policy confines the update to the selected active organization |
O6 — Suspend, reactivate, or archive organization
Actor: Ninaku platform staff (control plane) only. Owner decision (2026-09-30): the tenant never suspends, reactivates or archives its own organization; a reason is always required; archival is one-way; archival is destructive, so it also requires a live elevation (step-up) and an Idempotency-Key. The tenant-side suspension routes this catalog first proposed are superseded by SP17 and are not built. Intent: move the organization between active, suspended, and archived without ever deleting it. Preconditions: organization exists; for reactivation, the organization must be able to satisfy D21 immediately upon becoming active again. Invariants protected: D21's assert_administration_preserved — reactivating a suspended organization that never recovered administration is one of the paths D21 explicitly names as blocked; a suspended/archived organization keeps its history but is never treated as operable regardless of its administrator state. Main path:
- Caller requests a status transition with a reason.
UPDATE organization.organizations SET status = ...inside a transaction; the deferred constraint trigger evaluatesassert_administration_preservedat commit.- Reactivation additionally requires (per D21) that an active organization-wide administrator already exists, or the same transaction restores one — otherwise commit fails with
23514 · identity_last_administrator_required. Adversarial / edge cases:
- Reactivating a suspended organization that lost its last administrator while suspended — refused at commit, not silently allowed because the organization "was already broken."
- Suspending an organization that is mid-provisioning (no completed
platform.organization_provisioning_requestsrow yet) — D21's guarantee only starts once provisioning iscompleted; suspending an unprovisioned row is a no-op with respect to the invariant, but should still be rejected as a state that shouldn't exist yet. - Archiving is one-way (owner decision, 2026-09-30):
archivedis terminal. Staff reactivation, staff payments, the lifecycle sweep and a directUPDATEby any role are all refused or leave the organization archived (organizations_archived_terminal_trg,23514 · organizations_archived_terminal). - Archiving an organization that is already archived, or mid-provisioning, is refused and leaves one
deniedaudit record. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — staff only, in the Platform module: POST /platform-admin/organizations/{organizationId}/suspension and .../reactivation (SP17) and .../archival (elevation guarded, Idempotency-Key, { reasonText }). The tenant-side POST /organization/{suspension,reactivation,archival} are not built | Command | — | Platform (control plane) command over a table the Organization domain owns; guarded by Identity's D21 trigger | platform.suspend_organization_as_staff, platform.reactivate_organization_as_staff, platform.archive_organization_as_staff, organization.organizations, organizations_archived_terminal_trg, transitively identity.assert_administration_preserved | None: the commands are SECURITY DEFINER functions gated by platform.is_active_staff_member() and executed by ninaku_runtime, which holds SELECT, UPDATE on organization.organizations under RLS and never writes status itself |
3. Legal entity
O7 — Register legal entity
Actor: a member with organization-wide organization.account.manage (decided: no narrower legal-entity permission exists yet; one can be added to the permission catalog later without changing the route). Intent: declare a new legal entity under this organization. Preconditions: tenant context established; code unique per organization (case-insensitive); incorporation_country resolves to a real reference.countries row. Invariants protected: organization_id is immutable once set (prevent_organization_reassignment); correction against the reviewer's proposal — organization.legal_entities carries no tax-id/RUC/NIF column in this schema; fiscal registration numbers live in a separate fiscal schema (introduced in 0400_cross_domain_contracts.sql), not on this table. The "changing a tax id may be a new legal entity" guardrail is real, but it attaches to whichever aggregate actually stores the fiscal registration — see O8 and the open question in the final report. Main path:
- Caller submits
code,legal_name, optionaltrade_name,incorporation_country. INSERT INTO organization.legal_entities. Adversarial / edge cases:
- Duplicate
code(case-insensitive) within the same organization — refused by the unique index. - Unknown
incorporation_country— refused by the FK. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /legal-entities | Command | — | Organization | organization.legal_entities | None: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.legal_entities and no DELETE, and legal_entities_tenant_policy confines every read and write to the selected active organization. Migration 0038 only seeds the audit vocabulary (organization.legal_entity.registered, organization.legal_entity.updated, resource kind organization.legal_entity) |
O8 — Update legal entity profile
Actor: same as O7. Intent: rename a legal entity or change its trade name/status. Preconditions: legal entity belongs to the caller's tenant (RLS-scoped). Invariants protected: code and incorporation_country are treated as identity-defining and are not edited here — a change of jurisdiction or code means registering a new legal entity (O7) and retiring the old one (O22), not mutating this row in place. This preserves, without a tax-id column, the same spirit the reviewer's rule intended: identity-defining fields don't get silently rewritten. Main path:
- Caller submits
legal_nameand/ortrade_name. UPDATE organization.legal_entities SET legal_name = ..., trade_name = .... Adversarial / edge cases:
- Attempt to change
codeorincorporation_country— rejected at the API layer, not just left unmentioned. - Legal entity belongs to another organization — RLS makes the row invisible; the update affects zero rows, answered as a
404, not a403(never confirm existence across tenants). Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — PATCH /legal-entities/{id} | Command | — | Organization | organization.legal_entities | None: the grants and the policy of O7 already cover the update; the row is read FOR UPDATE under RLS, so another organization's legal entity is invisible and answered 404 |
4. Business unit
Correction against the reviewer's proposal: organization.business_units has no parent_id and no hierarchy at all — it is a flat list per organization. The hierarchy/cycle concern the reviewer expected here belongs to Outlet instead (see O18); it does not apply to Business Unit.
O9 — Register business unit
Actor: a member with organization.account.manage. Intent: create an operative/administrative subdivision of the organization. Preconditions: tenant context established; code matches ^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$ and is unique per organization (case-insensitive). Invariants protected: flat structure — no cycle to defend, because there is no parent column to misuse. Main path: submit code/name → INSERT INTO organization.business_units. Optional verticalCodes (D1, revision 109): 0 to 20 distinct codes matching ^[a-z][a-z0-9_]{1,79}$. After the insert the use case locks the new unit (BusinessUnitRepositoryPort.lock, the K10c precondition) and calls BusinessUnitVerticalsContract.activate once per code, in the same tenant transaction, so the verticals exist before the unit commits. The registration record comes first, then one organization.business_unit.vertical_activated record per vertical (the same record O11 writes). An unavailable outcome refuses the whole registration with 422 · organization.vertical_unavailable (the code of O11) and rolls it back; last_active_vertical cannot happen on an activation. What the new unit sells (D1 as built): Catalog's deferred trigger business_units_default_assortment_trg stores the unit's assortment when the registration commits: all for the first unit of the organization or when each of its active verticals already runs in another unit, none otherwise (also for a unit registered without verticals). It is stored once; a vertical turned on later does not change it. Organization never calls Catalog: the choice is Catalog's (CATALOG_OPTIONS_V2.md §18), and a client that wants another mode sends PUT /catalog/business-units/{id}/assortment right after registering; between the two requests the stored default applies. Adversarial / edge cases:
- Duplicate code within the organization — refused.
- Code violating the character-class check — refused.
- A repeated, malformed or 21st vertical code —
400. - An unknown, deprecated or retired vertical —
422, nothing stored. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /business-units | Command | — | Organization | organization.business_units | None: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.business_units and no DELETE, and business_units_tenant_policy confines every read and write to the selected active organization. Migration 0039 only seeds the audit vocabulary (organization.business_unit.registered, organization.business_unit.updated, resource kind organization.business_unit). Registering a business unit needs no vertical; the optional verticalCodes activates them through BusinessUnitVerticalsContract (Platform owns the rows, O11) in the same transaction. Catalog's deferred trigger on organization.business_units (migration 0099) stores the unit's default assortment at commit |
O10 — Update business unit profile
Actor: same as O9. Intent: rename a business unit or change its status (status changes are handled generically by O22). Main path: submit name → UPDATE organization.business_units. Adversarial / edge cases: foreign-tenant business unit — invisible via RLS, answers 404. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — PATCH /business-units/{id} | Command | Status transitions merged into O22 | Organization | organization.business_units | None: the grants and the policy of O9 already cover the update; the row is read FOR UPDATE under RLS, so another organization's business unit is invisible and answered 404. Only the name changes here; the code and the status do not |
O11 — Activate or deactivate a vertical for a business unit
Revised for J1 (D30). Which roles an invitation may offer depends on the verticals active in the business units of the chosen scope, so O11 ships before J1-2 (slice J1-O11). Without it, only the first business unit of an organization has verticals (provisioning activates them, J1 finding 12) and a second business unit, registered through O9, could never receive vertical roles. The fallback (offer the roles of every vertical active anywhere in the organization) was rejected: it lets an owner offer a waiter scoped to a retail-only store, and the rule would have to change, with open invitations already built on the loose one, the day O11 ships.
Actor: a member holding organization.account.manage organization-wide (the same check every Organization command uses). A plan-driven activation is not built; it waits for a plan that actually restricts verticals. Intent: turn Restaurant or Retail capabilities on or off for one business unit, independent of the other business units in the same organization. Preconditions: live session, tenant context (O3); the vertical exists and is active in platform.verticals; the business unit belongs to the tenant and is active. Invariants protected: activation is per (business_unit_id, vertical_code) (business_unit_verticals_unit_vertical_uq), not per organization — two business units in the same organization can run different vertical mixes (ARCHITECTURE §13–14). An active row needs an active organization, business unit and vertical (platform_business_unit_vertical_active_context); organization_id, business_unit_id, vertical_code and enabled_at are immutable (platform_business_unit_vertical_immutable); business_unit_verticals_state_chk ties disabled_at to status. Main path:
POST /business-units/{businessUnitId}/verticals/{verticalCode}/activation: no row →INSERT(status = 'active'), answer201; aninactiverow →UPDATE status = 'active', disabled_at = NULL(enabled_atstays the first activation: it is immutable, so the reactivation time lives in the audit record), answer200; anactiverow →200, nothing written.POST /business-units/{businessUnitId}/verticals/{verticalCode}/deactivation: anactiverow →UPDATE status = 'inactive', disabled_at = now(), answer200;inactiveor no row →200, nothing written.- Tenant audit record in the same transaction, only for a real change:
organization.business_unit.vertical_activated/organization.business_unit.vertical_deactivated(new action types, migration0056), resource kindorganization.business_unit(exists). As built, the one change carries the vertical in its attribute code,organization.business_unit.vertical.<verticalCode>, with the status as before (absent for a first activation) and after (activeorinactive). - Deactivation does not touch role assignments or open invitations of that vertical's roles (D30): assignments stay labels, the vertical's modules check the vertical per command (
platform.assert_operation_allowedtakesp_vertical_code), and an open invitation is refused at acceptance with422 · identity.invitation_role_vertical_inactive. Adversarial / edge cases:
| Path | Answer |
|---|---|
| No session / foreign tenant | 401 · identity.session_required / 403 · tenant.context_rejected |
Member without organization.account.manage organization-wide | 403 · organization.action_forbidden, audited as denied |
| Unknown business unit or another tenant's | 404 · organization.structure_not_found (RLS makes them the same) |
Unknown vertical code, or one deprecated or retired | 422 · organization.vertical_unavailable (new; backstops business_unit_verticals_vertical_fk, platform_business_unit_vertical_active_context); deactivating an existing active row of a deprecated vertical still works |
| Inactive business unit | 422 · organization.vertical_unavailable |
Malformed code (not ^[a-z][a-z0-9_]{1,79}$) or business unit id | 400 · foundation.validation_failed |
| Deactivating the organization's last active business-unit vertical | 409 · organization.last_vertical_required (new): provisioning refuses an organization without one (platform_provisioning_configuration_required), and an organization with none could offer only neutral roles. "Last" is per organization, counted over the active rows of its active business units: a unit may lose its only vertical while another unit keeps one, and an inactive row, another organization's rows and an active row inside an inactive unit never count |
| Activation repeated, or two activations raced | 200, one row: as built, the row lock on the business unit serialises the two requests, so the second one reads the first one's row and answers it without writing; the unique constraint is only a backstop and is not reached |
| Deactivating an inactive or never-activated pair, or an unknown code | 200 with status = 'inactive'; nothing is written or audited (as built) |
| Inactive or archived business unit on deactivation | 422 · organization.vertical_unavailable, like activation (as built) |
Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /business-units/{businessUnitId}/verticals/{verticalCode}/activation, .../deactivation | Command | — | Organization: it owns the business unit and the route, and it already writes the first activation through provisioning; the table lives in the platform schema | platform.business_unit_verticals, platform.verticals, organization.business_units, audit.records | None for the tables: runtime_grants.sql grants SELECT on platform.verticals and SELECT, INSERT, UPDATE on platform.business_unit_verticals, under business_unit_verticals_tenant_policy (J1 finding 13). New: the two audit action types. The last-vertical rule needs a per-organization lock (SELECT ... FOR UPDATE on the organization's active rows) so two concurrent deactivations cannot both pass. As built: no SQL function was needed. The repository in infrastructure/business-units/business-unit-vertical.repository.ts reads platform.verticals and reads and writes platform.business_unit_verticals directly with the existing grants and business_unit_verticals_tenant_policy (the module-boundary rules constrain TypeScript imports, not SQL, and provisioning already writes the same table), maps the trigger's platform_business_unit_vertical_active_context and the foreign key business_unit_verticals_vertical_fk to organization.vertical_unavailable by constraint name, and locks, in this order, the business unit row and then the organization's active rows of active units ORDER BY id FOR UPDATE OF (a fixed order, so concurrent deactivations cannot deadlock each other). Migration 0056 (revision 66) only seeds the two audit action types |
5. Brand
O12 — Register brand
Actor: a member with organization.account.manage. Intent: declare a commercial identity under the organization. Preconditions: code unique per organization (case-insensitive). Main path: submit code/name → INSERT INTO organization.brands. Adversarial / edge cases: duplicate code — refused. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /brands | Command | — | Organization | organization.brands | None: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.brands and no DELETE, and brands_tenant_policy confines every read and write to the selected active organization. Migration 0039 only seeds the audit vocabulary (organization.brand.registered, organization.brand.updated, resource kind organization.brand) |
O13 — Update brand profile
Actor: same as O12. Intent: rename a brand. Adversarial / edge cases: foreign-tenant brand — invisible via RLS, 404. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — PATCH /brands/{id} | Command | Status transitions merged into O22 | Organization | organization.brands | None: the grants and the policy of O12 already cover the update; the row is read FOR UPDATE under RLS, so another organization's brand is invisible and answered 404. Only the name changes here; the code and the status do not |
6. Site
O14 — Register site
Actor: a member with organization.account.manage. Intent: declare a physical or logical location group. Preconditions: code unique per organization; country_code resolves; timezone is a real IANA zone; both coordinates present or both absent. Invariants protected: the sites_validate_timezone_trg trigger checks the timezone against pg_timezone_names at write time — an invalid zone never reaches the row. Main path: submit fields → INSERT INTO organization.sites. Adversarial / edge cases:
- Invalid IANA timezone string — refused by the trigger, which names the constraint
sites_timezone_known; the repository reports an invalid timezone only for that name. - Latitude without longitude (or vice versa) — refused by the check constraint.
- Latitude/longitude out of range — refused by the check constraint. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /sites | Command | — | Organization | organization.sites | None: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.sites and no DELETE, and sites_tenant_policy confines every read and write to the active organization; migration 0040 only seeds the audit vocabulary |
O15 — Update site profile
Actor: same as O14. Intent: correct address, timezone, or coordinates. Adversarial / edge cases: same timezone/coordinate guards as O14 apply on update, not just insert (trigger fires on both). Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — PATCH /sites/{id} | Command | Status transitions merged into O22 | Organization | organization.sites | None: the grants and the policy of O14 already cover the update; the row is read FOR UPDATE under RLS, so another organization's site is invisible (404) |
7. Outlet
O16 — Open outlet
Actor: a member with organization.account.manage, scoped at least to the target business unit/site. Intent: stand up a point of sale/operation tied to one brand and one site, optionally nested under a parent outlet. Preconditions: brand_id and site_id belong to the same organization as the outlet (enforced by composite FKs, not just RLS); optional parent_outlet_id must belong to the same site_id and organization_id. Invariants protected: cross-tenant adversarial case — a foreign brand or foreign site cannot even be referenced: outlets_brand_organization_fk and outlets_site_organization_fk are composite FKs over (id, organization_id), so a brand_id/site_id from another tenant has no matching row to satisfy the FK at all, independent of RLS. Main path: submit code/name/brand_id/site_id/optional parent_outlet_id → INSERT INTO organization.outlets (fires outlets_hierarchy_trg when a parent is given). Adversarial / edge cases:
brand_id/site_idfrom another organization — refused by the FK, not by RLS (defense in depth; RLS would also hide the row, but the FK refuses the write outright).parent_outlet_idpointing at an outlet on a differentsite_id— refused by the composite FK on(parent_outlet_id, site_id, organization_id).parent_outlet_id = id(self-parent) — refused byoutlets_not_self_parent_chk. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /outlets | Command | — | Organization | organization.outlets | None: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on organization.outlets and no DELETE, and outlets_tenant_policy confines every read and write to the active organization; migration 0063 only seeds the audit vocabulary (organization.outlet.opened, organization.outlet.updated, organization.outlet.reparented, resource kind organization.outlet) |
As built (O16): body { code, name, brandId, siteId, parentOutletId?, businessUnitId?, legalEntityId? } (strict: any other field, such as the status or the organization, is 400), organization-wide organization.account.manage plus X-Organization-Id, 201 with { outletId, code, name, brandId, siteId, parentOutletId, status }. The code is the same bounded text as brands and sites (50 characters, unique per organization ignoring case). A code already taken is 409 organization.structure_code_taken. A brand, a site or a parent that does not exist, belongs to another organization, is inactive or (for the parent) sits on another site is 422 organization.structure_refused with one detail per kind of element and the same answer for unknown and foreign. As built, the database answers that before the composite foreign key: the activation trigger (organization_outlet_activation_brand and _site) fires first, so a foreign brand and an unknown one are the same refusal; the composite keys stay as the second guard. A self-parent cannot be opened, because the server generates the identifier. There is no plan limit for outlets and no vertical rule in the schema today, so none is invented. Audit: one organization.outlet.opened record with the code, name, brand and site (and the parent, the business unit and the legal entity when there are) as after values; a denied attempt is audited against the organization. With businessUnitId and legalEntityId (O20 and O21, decision D4) the same transaction also creates the assignments from today at the site of the outlet; an unknown or foreign business unit or legal entity is 404 organization.structure_not_found, the same answer as on the assign routes, because an assignment target is looked up as a resource (the composite foreign key failure maps to it), while an inactive one is 422 organization.structure_refused with its own detail; nothing is stored in either case, not even the outlet. The brand and the site are structural parents and keep 422 for unknown and foreign.
O17 — Update outlet profile
Actor: same as O16. Intent: rename an outlet. Invariants protected: brand_id/site_id are not edited by this case, and the owner decided they never change: the database refuses it (organization_outlet_site_brand_immutable, migration 0069), so they need no effective-dated history like the O20/O21 assignments. Adversarial / edge cases: attempt to change brand_id/site_id through this route — rejected at the API layer. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — PATCH /outlets/{id} | Command | Status transitions merged into O22 | Organization | organization.outlets | None: the grants and the policy of O16 already cover the update; the row is read FOR NO KEY UPDATE under RLS, so another organization's outlet is invisible (404) |
As built (O17): body { name } only and strict, so brandId, siteId, parentOutletId, code, status and any other field are 400; the decision on brand and site immutability stays as the owner left it. 200 with the outlet; a name equal to the stored one answers 200 with no write and no record. No ETag or If-Match: no structure route has them (D24 write side waits for a structure read). Unknown or foreign outlet: 404 organization.structure_not_found. Audit: organization.outlet.updated with the name before and after.
O18 — Re-parent an outlet
Actor: same as O16. Intent: move an outlet under a different parent outlet (or detach it to the top level) within the same site. Preconditions: new parent (if any) belongs to the same site_id and organization_id. Invariants protected: schema-enforced, not application-level — the outlets_hierarchy_trg trigger (a) takes a per-organization advisory transaction lock so two concurrent re-parents on the same tree serialize, (b) walks organization.outlet_ancestors() to refuse any move that would create a cycle, and (c) refuses activating an outlet whose ancestor chain contains an inactive outlet. Main path: submit new parent_outlet_id → UPDATE organization.outlets (fires the same trigger as O16). Adversarial / edge cases:
- Move that would make an outlet its own ancestor (direct or transitive) — refused with
23514 · organization_outlet_acyclic. - Activating (or leaving active) an outlet whose new parent chain has an inactive outlet — refused with
23514 · organization_outlet_parent_active. - Two concurrent re-parent requests on the same organization's tree — the advisory lock (
organization.outlet-tree:<org_id>) serializes them; the second waits, then re-evaluates against the first's result. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /outlets/{id}/re-parenting (task-oriented, not a raw field PATCH, because it carries cycle/ancestor semantics) | Command | — | Organization | organization.outlets, organization.outlet_ancestors() | None: the table grants of O16 cover the update and outlet_ancestors() is callable by the runtime role from the trigger |
As built (O18): body { parentOutletId: uuid | null } (required, strict); 200 with the outlet. The use case locks the outlet row, answers 200 with no write and no record when the parent is the one it already has, and otherwise updates it; the outlets_hierarchy_trg trigger does the rest, and the repository maps its constraint names only in the infrastructure layer: a cycle (organization_outlet_acyclic, which also covers moving an outlet under itself because the trigger fires before outlets_not_self_parent_chk) and an inactive ancestor (organization_outlet_parent_active) are 422 organization.structure_refused with their own detail; a parent that is unknown, foreign or on another site is the same 422 (composite foreign key). Two moves in one organization never run together: the trigger takes the per-organization advisory lock, so the second waits and is judged against the committed result (proven in the database with two sessions, in the repository with two transactions and over HTTP with opposite moves). The outlet row is locked FOR NO KEY UPDATE, not FOR UPDATE: the composite parent foreign key takes FOR KEY SHARE on the new parent, which FOR UPDATE blocks, so a move holding the advisory lock waited for a parent row locked by a move waiting for that same advisory lock, and PostgreSQL aborted one of them as a deadlock (40P01); three in a row exhausted the retries and answered 503. No use case deletes an outlet or changes its key columns, so the weaker lock still serializes every writer of the row. The inactive ancestor is reachable over HTTP since O22 (POST /outlets/{outletId}/deactivation). Audit: organization.outlet.reparented with the parent before and after, with no value when there is no parent.
8. Sales channel
O19 — Register sales channel
Actor: a member with organization.account.manage. Intent: declare a channel (pos/storefront/kiosk/marketplace/api/ phone/manual/import/other) through which sales occur. Note: the reviewer's list did not name this table; it is added here because 0020_organization.sql owns it as Core, alongside brand/site/outlet. It is kept deliberately light — no consumer module exists yet — and its implementation priority is an open question in the final report, not a commitment to build it early. Main path: submit code/name/channel_kind → INSERT INTO organization.sales_channels. Adversarial / edge cases: unknown channel_kind — refused by the check constraint; duplicate code — refused. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /sales-channels | Command | — | Organization | organization.sales_channels | No grant to ninaku_runtime today |
9. Effective assignments
O20 — Assign outlet to legal entity, effective from a date
Actor: a member with organization.account.manage. Intent: declare which legal entity an outlet trades under, from a given date, without erasing who it traded under before. Preconditions: outlet and legal entity belong to the caller's tenant and are active. Invariants protected: append-only history — protect_outlet_legal_entity_assignment refuses any UPDATE that changes outlet_id, legal_entity_id, or valid_from on an existing row (only valid_until/status may close it); no overlapping active periods for the same outlet — a GiST EXCLUDE constraint on (organization_id, outlet_id, daterange(valid_from, valid_until)) filtered to status = 'active'; no assignment to a legal entity that is not active — outlet_legal_entity_target_trg (organization.validate_outlet_assignment_target, migration 0069). Main path:
- The caller submits the legal entity and, optionally,
validFrom(default: today) andvalidUntil. - The use case first locks the organization-wide structure version row (
admitStructureWrite, the same first lock as every structure write), which is what serialises two assignments of one outlet, and of any two outlets of the organization; it then takes the outlet tree advisory lock and the outlet row (the same locks as O17 and O18), reads the active periods that reachvalidFrom, closes the one that covers it and inserts the new row, with the audit record, in one transaction. Adversarial / edge cases:
- Two concurrent "assign from today" commands for the same outlet — the structure version row lock, taken before the outlet row lock, serializes them: the first creates the row, the others find the same target current and answer
200(or, with another target, replace it the same day). If the lock were bypassed, the GiST exclusion still lets exactly one win and the other fails with23P01(proven with two connections). valid_until <= valid_from— refused by the period check constraint, and by the request schema before it reaches the database.- Legal entity from another organization — refused by the composite FK, and answered
404before it by the same read the use case does under RLS. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /outlets/{id}/legal-entity-assignments | Command | — | Organization | organization.outlet_legal_entity_assignments | None: runtime_grants.sql already grants ninaku_runtime SELECT, INSERT, UPDATE on both assignment tables (migration 0017) and no DELETE; migration 0069 only seeds the audit vocabulary and adds the activation guard |
As built (O20): body { legalEntityId, validFrom?, validUntil? } (strict, dates are YYYY-MM-DD), organization-wide organization.account.manage plus X-Organization-Id. Answers:
| Status | When |
|---|---|
201 { id, outletId, legalEntityId, validFrom, validUntil, status } | a row was created |
200 same body | the same legal entity is already current from that date: no write, no record; or, with a different validUntil, its end date moved (D5) with one record |
400 foundation.validation_failed | malformed body or date, unknown field, validUntil <= validFrom when both are sent |
401, 403 tenant.context_rejected | no session, or the organization header is missing or not the caller's |
403 organization.action_forbidden | the caller lacks the organization-wide permission (a member scoped to one business unit included); one denied record against the outlet |
404 organization.structure_not_found | unknown or foreign outlet or legal entity, with one and the same answer |
409 organization.assignment_conflict (new) | another active period of the outlet starts after validFrom, or a moved end would reach past its start (D5) |
422 organization.structure_refused | the outlet or the legal entity is not active, validFrom is before today, or validUntil is not after a validFrom that defaulted to today |
Decisions: D1 when a new assignment replaces the current one the previous row stays active with its period closed (valid_until = the new valid_from), so effective_legal_entity still answers for past dates; a row replaced before it took effect (it starts on the same day as the new one) becomes superseded. D2 "today" is the date in the timezone of the site of the outlet, computed in the repository (SELECT (now() AT TIME ZONE sites.timezone)::date); since migration 0070 identity.effective_permission and identity.role_offerable_at_scope pass the same site date to the resolvers effective_legal_entity and effective_business_unit; platform.assert_operation_allowed still passes the current_date of the database, which waits for the first vertical command that calls it. D3 an assignment cannot start in the past; validFrom defaults to today. D4 POST /outlets (O16) accepts the optional businessUnitId and legalEntityId and creates both assignments from today in the same transaction. An assignment target is looked up as a resource, so an unknown or foreign business unit or legal entity answers 404 organization.structure_not_found there exactly as on the assign routes, and an inactive one 422 organization.structure_refused; the brand and the site of the new outlet are its structural parents and keep 422 organization.structure_refused for unknown, foreign and inactive alike. Nothing is stored in any of these cases. Where the specification was silent: a row that starts on the same day as the new one is replaced, not a conflict (only a row that starts later is); the same target current from the date answers 200 even when a later row waits; validUntil on a new row closes the outlet's assignment at that date, it does not restore the previous one: a bounded assignment [d1, d2) closes the current one at d1 and leaves a gap from d2 on, and a gap is never filled (proven over HTTP: the resolver answers no target on d2). D5 (owner-delegated) repeating the same target with a different validUntil moves the end of the row that covers validFrom instead of answering 200 unchanged: a date sets or moves it, null clears it, and omitting validUntil leaves it as stored, which keeps the old idempotent repeat; the answer is 200 with the updated body and one audit record under the same action with the change organization.outlet.valid_until (before and after, no value when open). An identical end writes and records nothing; an end that is not after the start is refused like a new period (422 when validFrom defaulted, 400 when both are sent); an end that reaches past the start of a later assignment, or null while one waits, is 409 organization.assignment_conflict. Two concurrent moves serialise on the structure version row and leave one consistent row whose end is the after value of the last record. Audit: organization.outlet.legal_entity_assigned with the legal entity before (the one replaced, or no value) and after, and organization.outlet.valid_from with the date; a denied attempt is audited against the outlet. The history is read through GET /outlets/{outletId}/legal-entity-assignments (O24).
O21 — Assign outlet to business unit, effective from a date
Identical shape to O20, over organization.outlet_business_unit_assignments. Actor / Intent / Invariants: same as O20, substituting business unit for legal entity (outlet_business_unit_target_trg guards the activation). Adversarial / edge cases: same three rows as O20, substituting business unit for legal entity. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /outlets/{id}/business-unit-assignments | Command | Kept distinct from O20 — independent history tables, independent GiST constraints | Organization | organization.outlet_business_unit_assignments | None, as for O20 |
As built (O21): body { businessUnitId, validFrom?, validUntil? }, the same answers, codes and decisions D1 to D4 as O20, with organization.outlet.business_unit_assigned and organization.outlet.business_unit_id in the audit record. A vertical role (waiter, kitchen) can be offered or assigned at the scope of an outlet only once the outlet has a business unit that runs the vertical: an outlet with no assignment is still refused with 422 identity.invitation_role_vertical_inactive or 422 identity.role_assignment_vertical_inactive, and the same request answers 201 after this route (or businessUnitId in POST /outlets) gave it the business unit. Brand and site are immutable (O17): migration 0069 makes the database refuse any change of site_id or brand_id of an outlet (organization_outlet_site_brand_immutable). What the outlet sells (K16): an outlet sells what the assortment of the business unit that operates it today lists, minus the items the outlet hides (catalog.outlet_hidden_items, PUT /catalog/outlets/{id}/hidden-items, CATALOG_USE_CASES.md C30). Organization never reads or writes those rows: when a new business-unit assignment starts, they stay and apply to the assortment of the new unit, and a hidden item the new unit does not list is simply irrelevant.
10. Lifecycle and structure reads
O22 — Deactivate or reactivate a structural entity
Actor: a member with organization.account.manage. Intent: move an organization/business unit/legal entity/brand/site/ outlet/sales channel between its lifecycle states — active is always reachable again except where noted; nothing is ever deleted.
Invariants protected (per-aggregate lifecycle table — this replaces the reviewer's single generic case, because each aggregate's lifecycle is genuinely different):
| Aggregate | States | Reversible? | Notes |
|---|---|---|---|
organization.organizations | active / suspended / archived | suspended → active, gated by D21 | archived treated as terminal in this catalog |
organization.business_units | active / inactive / archived | inactive → active | validate_child_activation blocks activating a child whose parent is not active |
organization.legal_entities | active / inactive / archived | inactive → active | — |
organization.brands | active / inactive / archived | inactive → active | — |
organization.sites | active / inactive / archived | inactive → active | — |
organization.outlets | active / inactive / archived | inactive → active | additionally gated by outlets_hierarchy_trg's active-ancestor rule (O18) |
organization.sales_channels | active / inactive / archived | inactive → active | — |
identity.memberships | active / suspended / ended | suspended → active; ended is terminal (a returning person gets a new membership row) | different vocabulary from the rest — flagged so nobody assumes it matches |
identity.role_assignments | no status column at all | not applicable — revocation is a hard DELETE, not a soft transition | the one aggregate in this catalog that breaks "never delete"; see O28 |
identity.invitations | pending / accepted / revoked | none — both terminal states are final | a still-pending row may be deleted (to allow reissue, O33); accepted/revoked rows may never be deleted |
Main path: submit a target status with a reason → UPDATE the aggregate's status column; deferred triggers (D21 on the organization side, validate_child_activation/outlets_hierarchy_trg on the structural side) evaluate at commit. Adversarial / edge cases:
- Activating a business unit/outlet whose parent is not active — refused.
- Deactivating the organization's only active administrator's business unit scope in a way that would leave the organization without an admin — refused (D21, see O6/O28). Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /{aggregate}/{id}/deactivation, .../reactivation (one task-oriented pair per aggregate, not raw PATCH status) | Command | Absorbs the reviewer's O18 | Organization (structural aggregates); Identity (memberships) | see table above | see per-case rows above |
As built (O22, lot B, migration 0070, revision 80): ten routes, one pair per aggregate: POST /sites/{siteId}/deactivation and /reactivation, and the same for /brands/{brandId}, /legal-entities/{legalEntityId}, /business-units/{businessUnitId} and /outlets/{outletId}. No body; organization-wide organization.account.manage plus X-Organization-Id; the organization itself and the memberships keep their own staff and O29 routes.
| Status | When |
|---|---|
200 with the body of the aggregate (the same shape as its register and update routes) | the status changed; one organization.<kind>.deactivated or .reactivated record with organization.<kind>.status before and after |
200 same body | the element already has the status asked for: no write, no record |
400 foundation.validation_failed | the identifier is not a UUID |
401, 403 tenant.context_rejected | no session, or the organization header is missing or not the caller's |
403 organization.action_forbidden | the caller lacks the organization-wide permission (a cashier, a member scoped to one business unit); one denied record against the element |
404 organization.structure_not_found | unknown or foreign, one and the same answer |
409 organization.structure_in_use (new) | deactivation blocked by active dependents: a site or a brand with active outlets, an outlet with active child outlets, a legal entity or a business unit assigned to an active outlet today or from a later date |
409 organization.last_vertical_required | deactivating the business unit that holds the last active vertical of the organization |
422 organization.structure_refused | reactivating an outlet under an inactive site, brand or ancestor outlet (as O18), reactivating an outlet assigned (today or from a later date) to an inactive business unit or legal entity, or changing an archived row in either direction |
The database decides, and the repository maps constraint names only in the infrastructure layer: organization.guard_structure_deactivation is a BEFORE UPDATE OF status trigger on the five tables (23514 organization_<kind>_deactivation_in_use, organization_business_unit_last_vertical); reactivation reuses validate_child_activation and outlets_hierarchy_trg; for an outlet, validate_child_activation also locks FOR SHARE the business units and legal entities it is assigned to today or from a later date (the same assignments the deactivation guard counts) and refuses one that is not active (23514 organization_outlet_activation_business_unit and organization_outlet_activation_legal_entity), so closing the outlet and then its business unit can no longer be followed by reopening the outlet alone. The use case locks the row first (FOR UPDATE, FOR NO KEY UPDATE for an outlet), so a site deactivation and an outlet opening on it are serialised by the site row (the opening takes FOR SHARE): whichever commits first wins and the other is refused (proven with two connections). The outlet guard also takes the per-organization outlet-tree lock, so a parent cannot close while a child opens. The last-vertical guard locks the active vertical rows of the organization in id order, the same order as O11, so the two cannot both pass; the guard and the O11 turn-off also take one per-organization advisory lock (organization.verticals:) before reading the verticals, so a turn-off racing a business unit deactivation waits for it and then no longer counts the verticals of the closed unit (proven with two connections; locking the business unit rows instead deadlocks two turn-offs that each hold their own unit).
Decisions: D5 the sales channel lifecycle is proven at the database layer only, without a route (deferred with O19). D6 deactivating a parent with active dependents is refused, never cascaded; closing a branch is bottom-up and reopening it top-down. D8 an archived row is refused in both directions (there is no archive route). Closing an outlet leaves its assignments to a legal entity and a business unit untouched, so reopening it restores it as it was.
Inactive scopes (owner decision a). A role assignment scoped to a site, brand, business unit, legal entity or outlet that is inactive is kept as history but grants nothing through it, in every form (direct, descendants, effective assignment), and neither does a scope below the organization at a target outlet that is inactive. An organization-wide role keeps granting at an inactive outlet, and the administrator scope is organization-wide, so it is never affected. identity.effective_permission keeps its shape and join_collapse_limit = 1; the scope branch is evaluated by the new invoker function identity.access_scope_covers_outlet, which is only planned and run for a scope below the organization, so the organization-wide grant path does not pay for it. After a refusal, and only then, identity.permission_scope_unavailable answers whether the same membership would hold the permission if the scoped entities were active; the identity contract PermissionCheckContract.decide returns granted, denied or scope_unavailable from the two, and a member who never held a covering scope always gets denied. The public answer for it is meant to be 403 authorization.scope_unavailable with the detail "The place you are assigned to is not available. Contact your administrator."; no route checks a permission at an outlet yet (every route checks organization.account.manage organization-wide, the only permission seeded), so that answer has no route to travel on and is not built: the decision is proven at the contract and database layers and waits for the first outlet-scoped route. Inactive targets are not offered: identity.role_offerable_at_scope refuses a target that exists and is not active, so GET /offerable-roles answers an empty list for it, and a new invitation (422 identity.invitation_refused) or role assignment (422 identity.role_assignment_refused) to an inactive target is refused as before; the database also refuses an access scope on an inactive target for every role, with or without a vertical (23514 identity_role_assignment_scope_inactive, raised by identity.validate_access_scope_vertical).
Site date (an intended change of behaviour). The same migration resolves the effective business unit and legal entity of effective_permission and role_offerable_at_scope on the date of the time zone of the site of the outlet, (now() AT TIME ZONE site.timezone)::date, instead of the database current_date (UTC on Railway), like the O20 and O21 assignments (D2). Around midnight an assignment that starts or ends "today" at the site therefore grants or stops granting by the site's calendar, not the UTC one. The old-versus-new differential reports this class apart from the inactive-scope class: with a far-east (Pacific/Kiritimati) and a far-west (Pacific/Pago_Pago) site, at least one of them is on a different date than UTC at any instant, and every difference found is the site-date rule. A site cannot store a timezone PostgreSQL does not know (sites_timezone_known), so the site date cannot fail on a stored value.
O23 — Read organization structure
Actor: any member of the organization. Intent: see the whole structure — business units, legal entities, brands, sites, and the outlet tree with each outlet's currently-effective legal entity and business unit — in one composed read. Main path: one read model joins organization.business_units, legal_entities, brands, sites, outlets (with organization.outlet_ancestors() for the tree shape), and organization.effective_legal_entity()/effective_business_unit() for each outlet's current assignment. Adversarial / edge cases: an outlet with no current assignment (all prior rows superseded, no active row) — surfaced as null, not hidden or defaulted. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — GET /organization/structure | Read model | — | Organization | all organization.* tables above, plus the two resolver functions | No grant to ninaku_runtime on any of them today |
O24 — Read effective assignment history
Actor: any member with visibility into the target outlet (org-wide or scoped). Intent: see every legal-entity/business-unit assignment an outlet has ever had, not just the current one. Main path: read all rows (active and superseded) from organization.outlet_legal_entity_assignments/outlet_business_unit_assignments for the outlet, ordered by valid_from. Adversarial / edge cases: an outlet with a gap (a period with no active assignment of either kind) — the read surfaces the gap; it does not interpolate. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — GET /outlets/{id}/legal-entity-assignments/history, .../business-unit-assignments/history | Read model | — | Organization | organization.outlet_legal_entity_assignments, organization.outlet_business_unit_assignments | No grant to ninaku_runtime today |
As built (O23 and O24, migration 0072, baseline revision 82). The decisions the owner delegated: D10 the ETag of the structure comes from a version row per organization, organization.structure_versions(organization_id, version), moved forward by statement triggers, not from a content hash, so If-Match costs one locked row read; D11 every structure read needs organization-wide organization.account.manage, like every Organization route; D12 the composed read stops at 500 outlets and answers meta.truncatedtrue, the paged route serves the rest, and no plan limit is added.
GET /organization/structure?status=active|allanswers{ businessUnits, legalEntities, brands, sites, outlets }in one SQL statement (JSON aggregation), each element with the body its own route already answers, and the outlets as a flat list in opening order withparentOutletId,currentBusinessUnitIdandcurrentLegalEntityId(nullwhen nothing is assigned today; a gap is never filled in). Today is the date of the time zone of the site of the outlet, as in O20 and O21; the statement applies the rule oforganization.effective_business_unitandeffective_legal_entitythrough two lateral joins instead of calling them per outlet, which took the read of a 200-outlet organization from about 22 ms to about 2 ms inEXPLAIN (ANALYZE, BUFFERS).status=active, the default, leaves out every inactive or archived element so a selector never offers one;status=alllists them for administration screens.meta.truncatedsays whether more than 500 outlets exist.GET /organizationanswers the profile, the same bodyPATCH /organizationanswers.- Both reads answer
Cache-Control: private, no-cacheand a strong ETag"<organizationId>:<version>:<representation>"(structure-active,structure-all,profile), so the two filters never share a tag and a tag of one organization never matches another; a matchingIf-None-Matchanswers304with no body. Reads record nothing. The structure tag carries one more segment, a SHA-256 of the composed outlets with their current business unit and legal entity: those ids depend on the site date, which moves at midnight at the site or when avalid_untilpasses with no write and no new version, so without it an oldIf-None-Matchkept answering304with stale current ids. Proven over HTTP for both filters by moving a site fromPacific/Pago_PagotoPacific/Kiritimatiwith triggers disabled (an SQL fixture standing for the clock passing midnight, the version unchanged): the old tag stops matching, the new one matches, and nothing else moves the tag. The profile tag does not depend on dates and has no such segment. - Declared costs of D10: one version for the whole organization means two administrators editing unrelated elements can get
412from each other when both sendIf-Match, and the profile ETag moves on every structure write even when the profile did not change. Write responses do not return a new ETag, so a client reads again before its next conditional write. GET /sites/{siteId}/outlets?after&before&limitpages every outlet of the site, whatever its status, newest first on(created_at DESC, id DESC)with the signed cursor of the other collections (bound to the route, the organization and the site), default 50, at most 100. The existingoutlets_organization_site_idxserves it:EXPLAIN (ANALYZE, BUFFERS)over a site with 501 outlets reads 15 buffers in about a millisecond, so no index was added. The brief named the cursor parametercursor; the route follows the repository conventionafter/before.GET /outlets/{outletId}/legal-entity-assignmentsand.../business-unit-assignmentsanswer the whole history of the outlet, active and superseded rows, ordered byvalid_from, as a plain array of{ id, targetId, validFrom, validUntil, status }, the latest 500 rows at most, still in ascending order, withmeta.truncatedtruewhen older rows were left out (the statement selects byvalid_from DESCwith one row more than the cap and the response is ordered ascending). The/historysuffix planned above was dropped: the plural collection already names the history, and itsPOSTsibling creates one more row of it.- An unknown site or outlet and one of another organization are the same
404 organization.structure_not_found; a member without the permission is403 organization.action_forbidden(not audited, reads record nothing). If-MatchonPATCH /organizationand on every structure write (create, update, re-parenting, deactivation, reactivation and both assignments of sites, brands, legal entities, business units and outlets) is optional: an absent header still writes (D24); the ETag of any of the three representations of the current version passes, the structure tag whatever its assignment segment; anything else answers412 foundation.precondition_failedand writes nothing. The check is one shared function,admitStructureWrite, called by every structure write after its permission check: it locks the version rowFOR NO KEY UPDATEbefore any other structure lock, with or withoutIf-Match, so every structure write of an organization takes the version row first and the outlet tree advisory lock, the vertical lock and the row locks of lots A and B after it. Every outlet write (rename, re-parenting, status change and both assignments) takes the exclusiveorganization.outlet-tree:<org_id>advisory lock inOutletRepository.lockbefore the outlet row lock, becauseorganization.assert_outlet_operational(sales, cash and platform) takes the shared tree lock before itsFOR SHAREon the outlet row: taking the row first and the tree lock later in the trigger deadlocked (40P01) with a sale on the outlet, proven with two sessions indatabase/tests/concurrency/outlet-lock-order-concurrency.ts; the trigger that moves the version at the end of the statement updates a row its own transaction already holds, so no structure write can wait on another in the opposite order. A write that changes nothing (the same values) does not move the version.- The version moves through
organization.bump_structure_version, an invokerAFTER INSERT,UPDATEandDELETEstatement trigger function with the transition tablechanged_rowson the seven structure tables (three triggers each, PostgreSQL refuses transition tables on a trigger with more than one event), once per statement and organization changed; andorganization.track_organization_structure_version, a row trigger onorganization.organizationsthat creates the row of a new organization and moves it when the code, name, locale or currency really change (staff status changes do not). The runtime maySELECTthe table andUPDATEonly itsversioncolumn, understructure_versions_tenant_policy; existing organizations were backfilled with version 1. The table is justified (API and schema discipline) because no existing row changes on every structure write: the organization row would add a write lock on the tenant root to every structure write and conflict with the staff lifecycle. GET /session/contextaddsorganizationContext.selectedOrganization { id, code, name }(null when nothing is selected) next toselectedOrganizationId, read in the statement that already reads the roles, so the route keeps its 13 statements.
J1 — Bring my team in (journey)
Status: built and complete after slice J1-6. The cases it uses (O25, O28, O29, O30, O31, O32, O33) are revised below for this journey; the decisions it depends on are in J1 decisions and in DECISIONS.md D27 to D30 (D30: the system roles by vertical that an invitation offers).
Actors.
| Actor | Who | What they hold |
|---|---|---|
| Owner | The person who provisioned the organization (O1) | An active membership with the global organization_owner role, organization-wide scope, so organization.account.manage organization-wide |
| Invitee with an identity | A future manager, waiter, kitchen, bar, cashier, sales_associate or stock_keeper who already has a Ninaku identity | A verified, non-revoked identity.login_identifiers row for the invited email or phone, or none yet for that identifier |
| Invitee without an identity | The same people, never having used Ninaku | Only the invitation message |
| Worker | The ninaku_worker service (D26) | EXECUTE on the security outbox claim/record/purge functions only |
The story, in business steps. Two owners walk through it: Ana runs a restaurant (one business unit with the restaurant vertical active, chosen at provisioning); Bruno runs a clothing store (one business unit with retail active).
- Invite. Ana opens "Team", enters her manager's email, picks the scope (organization-wide, or one business unit, legal entity, brand, site or outlet) and then the role. The role list depends on the scope (D30): for her restaurant it offers
manager,cashier,waiter,kitchenandbar, neverorganization_owner,sales_associateorstock_keeper. She invites the manager organization-wide, then awaiterand akitchencook by WhatsApp scoped to her one outlet, and acashierby email. Bruno's list for his store offersmanager,cashier,sales_associateandstock_keeper; he invites asales_associateand astock_keeper. (O30) - Deliver. In the same transaction as the invitation row, one
invitationmessage is enqueued in the Identity security outbox with the invitation token sealed. The worker sends it by email (SES) or WhatsApp (Wasender) according to the identifier type, then wipes the ciphertext. The API never returns the token. (O30, D27) - Open the link. The invitee opens the link. The token travels in the URL fragment, so it never reaches a server log; the app posts it to the preview route and shows "Join Restaurant name as Waiter — invited as
c***@gmail.com", with sign-in and sign-up buttons and no "create your business" button. (O32 preview) - Invitee with an identity. They sign in. If the invited identifier is already verified on their identity, they accept. If they hold an identity but the invited identifier is not verified on it, they add and verify it through the existing add-sign-in-method flow (
OUTBOX_USE_CASES.mdOX-S2), then accept. (O32) - Invitee without an identity. They sign up with the same identifier the invitation names and confirm the sign-up code (the ordinary sign-up flow,
IDENTITY_CASE_MATRIX.md). The app keeps the token in memory across sign-up and never offers provisioning while it holds one (APP_SHELL_AND_ONBOARDING.md: an invitee does not accidentally create another organization). Then they accept. (O32) - Accept. One database transaction creates the membership, the role assignment and its access scope copied from the invitation, marks the invitation accepted and writes the tenant audit record. If the plan's seat limit is already used, acceptance is refused with a clear code and the invitation stays open. (O32, D29)
- The new member appears. The owner's "Team" list shows the manager, the waiter, the cook and the cashier with their role and scope, the open invitations, and
seatsUsed/seatLimit. (O25) - Control. The owner can revoke an invitation that is still open (O31), reissue one whose message was lost or that expired (O33), take a role away from a member (O28), and suspend a member, which frees the seat (O29).
Cases per step.
| Step | Cases | Route |
|---|---|---|
| 1 (role list) | O30 (offerable roles read) | GET /offerable-roles?scopeKind=...&scopeId=...&includeDescendants=... |
| 1, 2 | O30 | POST /invitations |
| 3 | O32 (preview) | POST /invitation-previews |
| 4, 5 | none new: sign-in, sign-up, add-sign-in-method (Identity) | existing routes |
| 6 | O32 | POST /invitation-acceptances |
| 7 | O25 | GET /memberships, GET /invitations |
| 8 | O31, O33, O28, O29 | POST /invitations/{invitationId}/revocation, POST /invitations/{invitationId}/reissue, DELETE /role-assignments/{roleAssignmentId}, POST /memberships/{membershipId}/suspension, POST /memberships/{membershipId}/reactivation, POST /memberships/{membershipId}/termination |
J1 findings: where the drafts of §11 and §12 were wrong
Verified against the canonical SQL and the code on staging (3bce74d):
- The grants already exist. Every decision table in §11 and §12 said "no grant to
ninaku_runtimetoday".database/access/runtime_grants.sql(migration0017_runtime_reaches_organization_platform_and_identity_rbac.sql) grantsSELECT, INSERT, UPDATE, DELETEonidentity.invitations,SELECT, INSERT, UPDATEonidentity.invitation_role_assignments,SELECT, INSERTonidentity.invitation_access_scopes,SELECT, INSERT, UPDATEonidentity.memberships,SELECT, INSERT, UPDATE, DELETEonidentity.role_assignments,SELECT, INSERT, DELETEonidentity.access_scopes,SELECTonidentity.roles/permissions/role_permissions, andEXECUTEonidentity.effective_permission,identity.assert_membership_seat_available,identity.assert_invitation_editableandplatform.effective_limit. - An invitation has no
statuscolumn. "Startspending" meansaccepted_at IS NULL AND revoked_at IS NULL; "expired" isexpires_at <= now()on such a row.identity.protect_invitation_lifecycle()refuses an insert that is not open (identity_invitation_initial_state). - An expired invitation still blocks a new one.
invitations_one_open_per_identifier_uqis partial onaccepted_at IS NULL AND revoked_at IS NULLonly (the SQL comment says it deliberately avoidsnow()), so an expired, never-revoked invitation keeps the identifier "open". O30 must revoke it in the same transaction before inserting the new one, which is exactly what that SQL comment prescribes. - Reissue is revoke-and-insert, not delete-and-insert.
protect_invitation_lifecycle()makestoken_hashandexpires_atimmutable ("revoke and issue another",identity_invitation_provenance). Deleting an open invitation is allowed by the trigger, but it destroys the evidence the audit record points at; the old O33 draft chose delete. - The invitee cannot accept through ordinary RLS.
invitations_tenant_policy,memberships_insert_policy,role_assignments_tenant_policyandaccess_scopes_tenant_policy(0050_security_rls.sql) all require an active tenant context, which requires an active membership the invitee does not have yet. The old O32 draft ("the command copies ...") cannot run asninaku_runtime. Acceptance needs a narrowSECURITY DEFINERentry point, the same shape asplatform.provision_organization_for_caller. - The acceptance trigger does not create the membership.
protect_invitation_lifecycle()only checks, on theaccepted_attransition, that the invitation is not revoked or expired (identity_invitation_usable) and that the accepting identity is an active human who owns the verified, non-revoked identifier (identity_invitation_verified_recipient). Copying the role assignments and scopes is application work inside the acceptance function. - The security outbox cannot carry an invitation today.
identity.security_outbox_messages.challenge_idisNOT NULLwith a foreign key toidentity.authentication_challenges, whoseidentity_idisNOT NULL, and an invitee may have no identity.identity.claim_security_outbox_messagesinner-joinsauthentication_challenges, so a row without a challenge would be leased and silently never returned to the worker. The details and the minimal change are in D27. - Only one role exists. The only seeded role is the global
organization_owner(with the only seeded permission,organization.account.manage), seeded at the end ofdatabase/canonical/0040_identity.sql.identity.roleshas no vertical column. Answered by D30: seven system roles, seeded by J1-R. - A person has no name.
identity.identitieshas no display name column, and no other table holds one. The member list can show only the member's verified identifiers. - Revocation audit store. The O28 draft points durable evidence at
audit.platform_records; a member changing their own organization is a tenant action, recorded inaudit.recordsthroughTenantAuditContractin the same transaction (AGENTS.md§4). J1 applies the tenant store to O28 and O29.
Verified on staging at 1b34b25 for the roles decision (D30):
- No vertical exists in a real database.
platform.verticalsis created indatabase/canonical/0060_platform.sqlbut no canonical file and no migration inserts a row; only the tests do (database/tests/sql/business_foundation_fixture.sqlinsertsrestaurant,hotelandretail; the provisioning specs insert their own). In a database built bynpm run db:prepare, everyPOST /organizationstherefore fails onbusiness_unit_verticals_vertical_fkand answers422 · organization.provisioning_refused("One of the verticals does not exist."). O1 was proven only against test-seeded verticals. Slice J1-V (migration0050) seedshospitality,restaurantandretail, and O1 is now proven against them. - Provisioning is how verticals become known before O11.
platform.provision_organizationinserts oneplatform.business_unit_verticalsrow (defaultstatus = 'active') per requested code for the first business unit, and its completion check (platform_provisioning_configuration_required) refuses an organization without at least one active business-unit vertical. So every provisioned organization has one business unit with at least one active vertical; a business unit registered later through O9 has none until O11 exists. - O11's grant note was wrong.
database/access/runtime_grants.sqlalready grantsSELECTonplatform.verticalsandSELECT, INSERT, UPDATEonplatform.business_unit_verticals, andbusiness_unit_verticals_tenant_policy(0060_platform.sql) confines them to the selected tenant. Corrected in O11. - J1-1 collided with staging and was renumbered. The branch
feat/invitation-delivery-schemaused migration0049and baseline revision 59, which staging had already used for0049_early_payment_keeps_access.sql. It was re-applied on staging as0053_invitations_travel_through_the_security_outbox.sqlat baseline revision 63.
J1 decisions
Proposed defaults, reversible, owned by the product owner who delegated them. Each is also recorded in DECISIONS.md (D27 delivery, D28 secret and acceptance, D29 limits, seats, privilege, membership disclosure and expiry, D30 roles by vertical).
| # | Decision | Default | Rejected alternative and why |
|---|---|---|---|
| a | Delivery | A fifth purpose, invitation, in the existing Identity security outbox; the token is sealed with the existing outbox cipher and wiped after delivery; email through SES, phone through Wasender, chosen by identifier_type | Build the tenant outbox (events.outbox_records) now: no code uses it, the worker does not claim it, and it would mean a second claim loop, a second dead-letter surface and tenant RLS for the worker, all for one message type that is a security message in nature (it carries a bearer secret) |
| b | The secret | 32 random bytes, base64url, sent only to the invitee; only sha256(token) is stored in token_hash; no response ever returns it; acceptance needs the token and a session whose identity owns the invited identifier verified | Return the link to the inviter so they can share it: a stolen inviter session could then harvest joinable tokens, and the "verified identifier" proof would be the only barrier left |
| c | Abuse by the inviter | Pending invitations per organization: plan limit identity.pending_invitations (20 on Pionero). New invitations per organization: 30 per rolling 24 hours (SQL constant). Per identifier per organization: one invitation message per hour across invite/reissue. Per identifier across all organizations: 3 messages per rolling 24 hours, beyond that delivery is deferred, not refused. Per IP: Throttler on invite/reissue (20 per 10 minutes) and on preview/accept (ANSWERS_A_CHALLENGE_RATE_LIMIT, 20 per 10 minutes) | Rely on the Throttler alone: it is per IP and per process, so one owner session behind several IPs could still make Ninaku spam a third party |
| d | Seats | An invitation reserves nothing; the existing deferred memberships_seat_limit_trg refuses an acceptance over the limit with 409 · identity.seat_limit_reached, and the invitation stays open; O25 answers seatsUsed and seatLimit | Reserve a seat per open invitation: idle invitations would block real hires and the seat trigger would need a second source of consumption |
| e | Privilege | J1 invitations need organization.account.manage organization-wide; the offered role must be active, global or of this organization, and must not grant organization.account.manage (so never organization_owner); enforced in the use case, again by identity.validate_invitation_role() (extended) and again inside identity.accept_invitation | Let any scoped holder of the permission invite inside their scope: the scope-coverage rule below is defined, but no scoped administrator exists yet and the existing Organization commands all require organization-wide |
| f | Already a member | Invite answers 409 · identity.invitee_already_member when the identifier is verified on an identity with a current (active or suspended) membership of this organization; a non-member identity's existence is never revealed | Silently send anyway: the acceptance would fail on memberships_identity_organization_current_uq and the owner would never learn why |
| g | Expiry | 7 days by default, 1 to 30 days on request; reissue revokes the old row (its token dies at once) and inserts a new row with a new token and a new expiry counted from the reissue | Extend the old row: expires_at and token_hash are immutable by trigger, and an extended old token keeps a possibly leaked link alive |
| h | Roles offered | Seven system roles seeded by migration with no permissions (D30): neutral manager, cashier; restaurant: waiter, kitchen, bar; retail: sales_associate, stock_keeper. An invitation offers the neutral roles plus the roles whose vertical is active in at least one business unit the chosen scope covers | One list without verticals (a store owner would be offered "Kitchen"); per-organization copies on vertical activation (N copies for every permission migration); a rank between roles (duplicates permissions and hides what is delegated) |
| i | Delivery seam | The use case depends on InvitationDeliveryPort (schedule(context, delivery), withdraw(context, invitationId), context = the tenant TransactionContext); today's adapter calls identity.enqueue_invitation_message / identity.withdraw_invitation_message; the D29 limits stay in that SQL function, and any future adapter must carry them | Call the outbox repository from the use case: welds the use case to one transport and makes a Notifications move a rewrite |
Containment rule (general form; J1 builds its first step). An inviter may offer a role at a scope only when all three hold:
- every permission of the role is among the inviter's own effective permissions at a scope that covers the offered scope;
- the offered scope is covered by one of the inviter's administrative scopes (an access scope of a role assignment that grants
organization.account.manage); - the role contains no administrative, non-delegable permission.
Coverage: organization-wide covers everything; a business unit, legal entity, brand or site scope covers the same dimension and value only; an outlet scope covers that outlet, and its descendants only when include_descendants is true on both. Nobody can offer organization-wide unless they hold it.
J1 builds it as: the inviter needs organization.account.manage organization-wide (so 2 holds for any scope), the offered role must not grant organization.account.manage (3, a single-code check), and 1 holds because the offerable roles carry no permissions yet (D30) and, from the day a module gives them some, organization_owner receives the same permissions in the same migration. When a second administrative permission exists, the single-code check becomes an attribute on identity.permissions marking a permission administrative (a new column, not in the schema today). The same rule is checked in the use case, in identity.validate_invitation_role() and inside identity.accept_invitation; identity.enqueue_invitation_message additionally checks in SQL that identity.current_membership_id() holds organization.account.manage organization-wide, because identity.invitations.invited_by_membership_id is nullable and the function otherwise trusts any tenant context (J1-1b).
Offering rule by vertical (D30). The business units a scope covers are: organization-wide → its active business units; a business unit → that one; an outlet → its effective business unit today (organization.effective_business_unit(org, outlet, current_date)), plus those of its descendants with include_descendants; a brand, site or legal entity → the effective business units today of its outlets. A vertical role is offerable when its vertical_code is active in platform.business_unit_verticals for at least one of them; a neutral role always is; a scope that covers no business unit offers only neutral roles.
Owner questions, answered
- The starter roles. Answered by the owner (2026-10-02) and recorded in D30: seven system roles by vertical, seeded with no permissions.
- A co-administrator. Default for J1: no. An invitation never offers a role that grants
organization.account.manage, so the owner stays the only administrator. Making a second administrator is a later decision for a later journey, most likely as "promote an existing member" with step-up rather than an invitation, so the person is already known and verified before receiving full control. O26 as built (J1-7) does not do it: it refuses every role that grantsorganization.account.manage, so nobody becomes an administrator over HTTP and the co-administrator stays a later decision. - A person's name. Not needed for J1: the team list shows the member's verified email or phone. When a name is added it belongs to the person's identity and is set by that person, never typed by the inviter (an inviter-typed label would be an unverified claim shown as fact).
11. Memberships and roles
Ownership note: memberships, roles, permissions, role_permissions, role_assignments, and access_scopes physically live in the identity schema (0040_identity.sql), and ARCHITECTURE.md's build order names "membresías" explicitly as part of the Identity module (step 10), built on top of the Organization/Platform topology modules (step 9). This catalog therefore proposes these cases are implemented by Identity, not Organization, with Organization exposing only the organization/business-unit/ brand/site/outlet identifiers that access scopes reference. Organization and Identity collaborate through contracts/, never by importing each other's repositories (D18, R1).
O25 — List organization members
Revised for J1.Actor: a member holding organization.account.manage organization-wide (identity.effective_permission(membership, 'organization.account.manage', NULL)), in the tenant named by X-Organization-Id. Intent: see who is in this organization (role and scope of each member), who is invited and not yet in, and how many seats are left. Preconditions: live session; active membership in the selected tenant (O3). Invariants protected: tenant isolation (memberships_select_policy, role_assignments_tenant_policy, access_scopes_tenant_policy, invitations_tenant_policy); the token hash is never read into a response; a member's identifiers are shown only to an administrator of an organization that person belongs to or was invited to. Main path:
GET /memberships— keyset page overidentity.membershipsof the tenant,(joined_at DESC, id DESC), default 50, maximum 100, opaque signed cursor; each item:membershipId,status,joinedAt,suspendedAt,endedAt, the member's verified, non-revoked identifiers fromidentity.login_identifiers({ type, value }; there is no name, J1 finding 9), androleAssignments, one entry per role assignment and access scope withroleAssignmentId, the rolecode,nameandverticalCode(nullfor a neutral role),assignedAtand its scope ({ kind, id, includeDescendants }:organization, or the one dimension and id).?status=active|suspended|endedfilters one status; defaultactive,suspended.meta.seats = { used, limit }:usedcountsstatus = 'active'memberships (the same countidentity.assert_membership_seat_availableuses),limitisplatform.effective_limit(org, 'identity.active_memberships')(nullwhen unlimited or undefined). Both are one indexed query each.GET /invitations— keyset page overidentity.invitationsof the tenant oninvitations_organization_idx(organization_id, created_at DESC)plusid; each item:invitationId,identifierType, the masked identifier (asPOST /invitationsanswers it; the stored address is never returned),state(open,expired,accepted,revoked, derived from the timestamps),expiresAt,createdAt, the inviter'smembershipId,replacesInvitationId, the offered roles (roleId,code) with their scopes, anddeliveryState, the state of the newest message of the invitation (pendingfor pending or leased,sent,failedfor a dead letter,nullwhen there is no message) without any ciphertext or destination.?state=open|expired|accepted|revokedfilters one state; defaultopen,expired, the invitations still waiting for an answer. Adversarial / edge cases:
| Path | Answer |
|---|---|
| No session | 401 · identity.session_required |
X-Organization-Id of an organization the caller has no active membership in | 403 · tenant.context_rejected |
Member without organization.account.manage organization-wide (a cashier, or a manager scoped to one outlet) | 403 · identity.action_forbidden; no audit record (reads are not audited), the refusal is logged and metered |
| Tampered, expired or foreign cursor | 400 · collection.cursor_tampered / collection.cursor_expired / collection.cursor_incompatible |
| Another tenant's rows | Never returned (RLS), even with a hand-made cursor |
| A role assignment with no access scope | Cannot exist (role_assignments_scoped_trg is deferred and requires one); not a branch |
An invitation's token_hash | Never selected by the read model |
Audit: reads are not audited. Idempotency: a read. ETag: none (D24: paginated lists do not carry one); Cache-Control: private, no-cache. Plan limit: reads identity.active_memberships, enforces nothing. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — GET /memberships, GET /invitations | Read model | The invitation list is folded in here (J1 step 7) | Identity | identity.memberships, identity.login_identifiers, identity.role_assignments, identity.roles, identity.access_scopes, identity.invitations, identity.invitation_role_assignments, identity.invitation_access_scopes, platform.effective_limit(); the delivery outcome comes from a new SECURITY DEFINER read identity.invitation_delivery_states(uuid[]) because the runtime has no SELECT on identity.security_outbox_messages | None for the tables (migration 0017); EXECUTE on the new delivery-state function. A keyset index on identity.memberships (organization_id, joined_at DESC, id DESC) is added only if EXPLAIN (ANALYZE, BUFFERS) shows a sort or a sequential scan (D24) |
O26 — Assign a role to a member
As built (J1-7): POST /memberships/{membershipId}/role-assignments with { roleId, scope } answers 201 with { roleAssignmentId, membershipId, roleCode, scope }, or 200 with the same body when the member already holds that role at that scope. The journey still gives a member their first roles through the invitation (D30); O26 is how a member whose role was taken away (O28), or who needs one more, gets another without being invited again. Details in "J1-7 as built".
Actor: a member with organization.account.manage at a scope that covers the target scope being granted. Intent: grant an existing member a role at a specific scope (org-wide, or narrowed to a business unit/legal entity/brand/site/outlet). Preconditions: target membership is active; role is active and either global or belongs to the same organization; not already assigned ((membership_id, role_id) unique). Invariants protected: a role assignment must carry exactly one scope dimension (or none, for org-wide) — access_scopes's single-dimension check and the deferred role_assignments_scoped_trg both apply; adding a seat also re-checks platform.effective_limit(org, 'identity.active_memberships') if this assignment is bundled with a new membership (it usually is not — memberships are created by O1/O32, not here — but the seat-limit adversarial case is listed under O32 for that reason). Main path: submit membership_id, role_id, scope → INSERT INTO identity.role_assignments + INSERT INTO identity.access_scopes in the same transaction. J1 note: an invited person's first role assignment is created by O32 from the invitation, never by this route. O26 applies the same privilege rule as O30 (J1 decision e: the role must not grant organization.account.manage, the caller needs it organization-wide) and the same codes (403 · identity.action_forbidden, 403 · identity.role_assignment_privilege_exceeded, and 422 · identity.role_assignment_refused for an unknown, inactive or foreign role or scope target). It also applies the D30 offering rule: a vertical role (waiter, kitchen, bar, sales_associate, stock_keeper) is assignable only at a scope that covers at least one business unit with that vertical active, refused with 422 · identity.role_assignment_vertical_inactive; the SQL backstop is the same coverage function J1-R adds, called from the trigger access_scopes_vertical_trg that migration 0062 adds on identity.access_scopes (the role row is inserted before its scope, so the check cannot live on identity.role_assignments). The general containment rule (J1 decisions) replaces the single-code check once scoped administrators exist. Adversarial / edge cases:
- Role already assigned to this membership — refused by the unique index.
- Role belongs to a different organization — refused by the FK.
- Scope names more than one dimension at once (e.g., both a business unit and an outlet) — refused by the single-dimension check. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /memberships/{id}/role-assignments | Command | — | Identity | identity.role_assignments, identity.access_scopes | No grant to ninaku_runtime today |
O27 — Change a role assignment's access scope
As built (J1-7): PATCH /role-assignments/{roleAssignmentId}/scope with { scope } answers 200 with { roleAssignmentId, membershipId, roleCode, scope }. The D21 path it names (narrowing the last administrator's scope) is refused inside the call with 409 · identity.last_administrator_required. Details in "J1-7 as built".
Actor: same as O26. Intent: narrow or widen where an existing role assignment applies (e.g., move an administrator's scope from org-wide to one outlet). Invariants protected: D21 explicitly names "reducir su alcance a una sucursal o a otra dimensión" as one of the paths that must not leave an active, admitted organization without an org-wide administrator — this is not a variant of O26, it is the exact case D21 calls out by name. Main path: because identity.access_scopes has no update-tracking column and role assignments carry no revision history, this case is modeled as delete the old access scope, insert the new one, in the same transaction that also re-evaluates the deferred D21 trigger — never an in-place field update, so provenance is never silently lost. Adversarial / edge cases:
- Narrowing the organization's last org-wide administrator's scope down to one outlet — refused at commit by
assert_administration_preserved. - Narrowing scope while another org-wide administrator already exists — allowed. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — PATCH /role-assignments/{id}/scope | Command | — | Identity | identity.access_scopes, transitively identity.assert_administration_preserved | No grant to ninaku_runtime today |
O28 — Revoke a role assignment
As built (J1-6): DELETE /role-assignments/{roleAssignmentId} answers 200 with { roleAssignmentId, membershipId, roleCode } (the draft said 204); the repository deletes the scope rows, then the assignment, and settles the deferred checks inside the call, so D21 is answered 409 · identity.last_administrator_required instead of surfacing at commit. Details in "J1-6 as built".
J1 cross-reference: used by J1 step 8 ("control" slice). The audit record is a tenant record (audit.records, TenantAuditContract, same transaction), not audit.platform_records (J1 finding 10).
Actor: same as O26. Intent: take a role away from a member. Invariants protected: D21 (deleting the last administrative role assignment for an org is one of the nine trigger-guarded tables); this is the aggregate that breaks "never delete" — identity.role_assignments has no soft-lifecycle column, so revocation is a real DELETE, with only assigned_at/assigned_by (already written) as provenance; anything more durable than that lives in the audit trail (audit.platform_records), not in this table. Main path: DELETE FROM identity.access_scopes WHERE role_assignment_id = ... then DELETE FROM identity.role_assignments WHERE id = ..., in one transaction; deferred D21 trigger evaluates at commit. Adversarial / edge cases:
- Revoking the organization's last org-wide administrative role assignment — refused at commit.
- Revoking while a second org-wide administrator exists — allowed.
- Concurrent revoke of two different administrators' assignments in the same organization — the per-organization advisory lock (
identity.lock_administration_tenant) serializes them so the check never race-passes both. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — DELETE /role-assignments/{id} | Command | — | Identity | identity.role_assignments, identity.access_scopes | No grant to ninaku_runtime today |
O29 — Suspend or end a membership
As built (J1-6): three routes, POST /memberships/{membershipId}/suspension, /reactivation and /termination, each 200 with { membershipId, status, suspendedAt, endedAt }. Reactivation is added to the draft: it takes a seat again and is refused over the limit (409 · identity.seat_limit_reached). Ending is final (409 · identity.membership_ended on suspension or reactivation of an ended membership). Details in "J1-6 as built".
J1 cross-reference: used by J1 step 8 ("control" slice). Suspending frees a seat (identity.check_membership_seat counts status = 'active' only); reactivating a suspended membership consumes one again and is refused over the limit. Audited in the tenant store (J1 finding 10).
Actor: same as O26. Intent: temporarily suspend a member, or end their relationship with the organization for good. Invariants protected: D21 (memberships are one of the nine trigger-guarded tables); ending is terminal — a person who later rejoins gets a new memberships row (identity.validate_membership_transition), never a reopened old one. Main path: UPDATE identity.memberships SET status = 'suspended', ... or SET status = 'ended', ended_at = now(). Adversarial / edge cases:
- Suspending/ending the organization's last active org-wide administrator's membership — refused at commit by D21.
- Re-inviting a person whose membership previously ended — creates a new membership row (O32/O31), not a reactivation of the old one. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /memberships/{id}/suspension, .../termination | Command | — | Identity | identity.memberships | No grant to ninaku_runtime today |
12. Invitations
Ownership note: same as §11 — identity.invitations and its child tables live in the identity schema; proposed owner is Identity, with Organization contributing only the target organization/scope identifiers.
Codes used by O30 to O33. Every refusal is a Problem Details body (FOUNDATION_HTTP_AND_OBSERVABILITY.md), detail in English (D25). New codes are marked new; the others exist today.
| Status · code | Meaning |
|---|---|
400 · foundation.validation_failed | Body or path fails its strict Zod schema (unknown field, bad UUID, identifier not an email or an E.164 phone, expiresInDays outside 1 to 30, more than one scope dimension in one scope object, NUL character) |
401 · identity.session_required | No live session |
403 · tenant.context_rejected | X-Organization-Id names an organization the caller has no active membership in |
403 · identity.action_forbidden new | The caller lacks organization.account.manage organization-wide; mirrors organization.action_forbidden |
403 · identity.invitation_privilege_exceeded new | The offered role grants organization.account.manage (J1 decision e) |
404 · identity.invitation_not_found new | No invitation with this id in this tenant (O31, O33), or no invitation with this token (O32) |
409 · identity.invitation_closed new | The invitation is accepted, revoked or expired (the three are not told apart), or its organization is not active |
409 · identity.invitation_already_open new | An open, unexpired invitation for this identifier exists with a different role or scope configuration |
409 · identity.invitee_already_member new | The identifier is verified on an identity with a current membership of this organization (O30), or the accepting identity already has one (O32) |
409 · identity.invitation_pending_limit_reached new | The organization already has as many open invitations as its plan's identity.pending_invitations |
409 · identity.seat_limit_reached new | Accepting would exceed identity.active_memberships (identity_membership_seat_limit) |
403 · identity.invitation_recipient_mismatch new | A valid token, but the caller's identity does not own the invited identifier verified |
422 · identity.invitation_refused new | Role unknown, inactive or archived (a retired role, D30) or of another organization; scope target unknown, inactive or of another organization; includeDescendants without an outlet |
422 · identity.invitation_role_vertical_inactive new | A vertical role offered at a scope where its vertical is active in no covered business unit (D30), at invitation or, after a deactivation, at acceptance |
429 · identity.invitation_rate_limited new | Organization daily cap or per-identifier cooldown (J1 decision c); carries Retry-After |
429 · rate_limit.exceeded | The per-IP Throttler |
O30 — Invite a person to the organization
Revised for J1.Actor: a member holding organization.account.manage organization-wide (J1 decision e). Intent: offer membership, with one or more pre-configured roles and scopes, to a person known only by an email or a phone, whether or not they have a Ninaku identity, and get the offer to them. Preconditions: live session; tenant context (O3); the organization is active. Invariants protected:
- The invitation targets a bare identifier, never an
identity_id; whether an identity exists is decided only at acceptance (O32). The response is the same whether or not the identifier belongs to someone, except the already-member case of this organization (J1 decision f). - The token exists in clear only in memory while it is generated, hashed into
token_hashand sealed into the outbox row; no response, log or audit record carries it (D28). - One open invitation per
(organization_id, identifier_type, normalized_value)(invitations_one_open_per_identifier_uq). - The invitation row, its
invitation_role_assignments, itsinvitation_access_scopes, its outbox message and its audit record commit or roll back together. - Roles and scopes are frozen once the invitation closes (
identity.assert_invitation_editable,identity_invitation_editable). Main path:
POST /invitationswithidentifierType(email|phone),identifier,roles(1 to 5 items, eachroleIdplus onescope:{ "kind": "organization" }or{ "kind": "business_unit" | "legal_entity" | "brand" | "site" | "outlet", "id": uuid, "includeDescendants"?: boolean }), optionalexpiresInDays(1 to 30, default 7).- Normalize the identifier with the same normalizer sign-up uses (lower-cased email; E.164 phone), so acceptance matches
login_identifiers.normalized_valueexactly. - Check the permission (
identity.effective_permission(caller, 'organization.account.manage', NULL)); a refusal is audited and answered403. - Check each role: exists,
active, global or of this organization, and grants noorganization.account.manage(the J1 step of the containment rule); check each scope target belongs to this organization and is active; check each vertical role against its scope with the offering rule (D30): itsvertical_codemust be active in at least one business unit the scope covers. - If the identifier is verified (
verified_at IS NOT NULL AND revoked_at IS NULL) on an identity holding a membership of this organization withended_at IS NULL, answer409 · identity.invitee_already_member. - Lock the open invitation for the identifier, if any (
FOR UPDATE):- open, unexpired, same role/scope set → answer
200with it, enqueue nothing (natural idempotency, below); - open, unexpired, different set →
409 · identity.invitation_already_open; - open but expired → set its
revoked_at = now()in this transaction (J1 finding 3), audited asidentity.invitation.revokedwith reasonexpired_replaced.
- open, unexpired, same role/scope set → answer
- Insert
identity.invitations(token_hash = sha256(token),invited_by_membership_id= caller's membership,expires_at = now() + expiresInDays), then oneidentity.invitation_role_assignmentsrow and oneidentity.invitation_access_scopesrow per offered role. - Call
InvitationDeliveryPort.schedule(context, { invitationId, token })with the same tenantTransactionContext(J1 decision i). Today's adapter seals the token with the outbox cipher and callsidentity.enqueue_invitation_message(message_id, invitation_id, ciphertext, nonce, key_version)(D27). The function takes the destination from the invitation row itself (the API cannot send to any other address), checks that the calling membership holdsorganization.account.manageorganization-wide (J1-1b), enforces the pending cap, the daily cap and the per-identifier cooldown under a per-organization and a per-identifier advisory lock, and defersavailable_atwhen the cross-organization cap is reached, never later than one hour before the invitation'sexpires_atand counting only the other invitations'pending,leasedandpublishedmessages (D29, J1-1b). - Record the tenant audit record and commit. Answer
201with the invitation (no token, no link). Adversarial paths:
| Path | Answer |
|---|---|
| No session / foreign tenant | 401 · identity.session_required / 403 · tenant.context_rejected |
A cashier, a waiter, or a manager (any scope: manager grants no organization.account.manage) invites | 403 · identity.action_forbidden, audited as denied, nothing else written |
Any tenant session calls identity.enqueue_invitation_message without organization.account.manage organization-wide (a compromised API path) | The function raises 42501 with constraint identity_invitation_inviter_forbidden (new, J1-1b), nothing enqueued; the use case checked first, so over HTTP this surfaces only as the 403 above or, if the use case were bypassed, an unmapped 500 |
Offered role is organization_owner, or any role granting organization.account.manage | 403 · identity.invitation_privilege_exceeded, audited as denied |
| Role of another organization, inactive role, unknown role | 422 · identity.invitation_refused (backstop: identity.validate_invitation_role()) |
A retired system role (status inactive or archived, D30) | 422 · identity.invitation_refused (backstop: identity.validate_role_assignment, called by identity.validate_invitation_role()) |
Ana (restaurant only) offers stock_keeper, or any vertical role at a scope whose business units do not run its vertical (including an outlet with no effective business unit) | 422 · identity.invitation_role_vertical_inactive, nothing written (backstop: the new trigger on identity.invitation_access_scopes, J1-R) |
manager or cashier at any scope, or waiter organization-wide when one business unit runs restaurant | 201: neutral roles are always offerable; organization-wide covers every active business unit |
| Scope target of another organization or unknown | 422 · identity.invitation_refused (backstop: the composite foreign keys invitation_access_scopes_*_organization_fk) |
Two dimensions in one scope; includeDescendants on a non-outlet scope | 400 · foundation.validation_failed (backstops: invitation_access_scopes_single_dimension_chk, invitation_scopes_descendants_chk) |
| The same role offered twice | 400 · foundation.validation_failed (backstop: invitation_role_assignments_invitation_role_uq) |
| Identifier verified on a current member (active or suspended) of this organization | 409 · identity.invitee_already_member |
| Identifier verified on someone who is not a member, or on nobody | 201, the same body shape and timing class; nothing reveals which |
| Open, unexpired invitation for the identifier with another configuration | 409 · identity.invitation_already_open; the owner revokes (O31) or reissues (O33) |
| Exact retry of a successful request | 200 with the same invitation, no second message |
| Two identical requests race | One inserts; the other hits invitations_one_open_per_identifier_uq, re-reads and answers 200 with the winner (or 409 if the configuration differs) |
Open invitations already at identity.pending_invitations | 409 · identity.invitation_pending_limit_reached |
| More than 30 invitations in 24 hours for this organization, or a second message to the same identifier within one hour | 429 · identity.invitation_rate_limited with Retry-After |
| The identifier already received 3 invitation messages in 24 hours from any organizations | 201; the message is enqueued with a later available_at (no refusal, so nothing leaks about other organizations) |
| More than 20 requests in 10 minutes from one IP | 429 · rate_limit.exceeded |
| Organization suspended or archived | 403 · tenant.context_rejected (an inactive organization yields no tenant context) |
Offerable roles read (J1 step 1). GET /offerable-roles?scopeKind=organization|business_unit|legal_entity|brand|site|outlet&scopeId=<uuid>&includeDescendants=<bool> answers the roles the caller may offer at that scope, computed with the same coverage function as the SQL backstop, so the form and the refusal never disagree: each item roleId, code, name (English; the app shows its own label from code, D30), verticalCode (null for neutral). Same actor as O30 and, as built, the codes of the structure routes (401, 403 · tenant.context_rejected, 403 · organization.action_forbidden, 404 · organization.structure_not_found for an unknown or foreign scope target, 400 · foundation.validation_failed for a malformed query or a scopeId with scopeKind=organization). Never lists organization_owner, a retired role, or another organization's custom role. Not paginated (at most a few dozen rows), no ETag (it changes with O11), Cache-Control: private, no-cache. Not audited.
Audit: identity.invitation.created (new action type, new resource kind identity.invitation), outcome success, target the invitation; changes after-only: invitation.identifier_type, invitation.identifier masked (c***@gmail.com, +57*******12), invitation.role_codes, invitation.scope per role, invitation.expires_at. Denied attempts write the same action with outcome denied and no changes. Never the token. Idempotency: natural, no Idempotency-Key: the open-invitation index plus "same configuration answers the existing one" makes a retry harmless, and no second message is sent. Plan limit: identity.pending_invitations (new limit definition) is checked; identity.active_memberships is not (J1 decision d). Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /invitations; GET /offerable-roles (read, below) | Command | Absorbs the expired-open auto-revoke | Identity | identity.invitations, identity.invitation_role_assignments, identity.invitation_access_scopes, identity.login_identifiers, identity.memberships, identity.roles (with the new vertical_code), identity.role_permissions, platform.business_unit_verticals and organization.effective_business_unit() (through the J1-R coverage function), identity.security_outbox_messages (through the new function, behind InvitationDeliveryPort), audit.records | Tables: none (migration 0017). New: EXECUTE on identity.enqueue_invitation_message; seeds for the identity.invitation resource kind, the identity.invitation.* action types and the identity.pending_invitations limit definition |
O31 — Revoke an invitation
Revised for J1.Actor: same as O30 (any organization-wide administrator, not only the inviter: the inviter may have left). Intent: withdraw an offer before it is accepted, so the link stops working at once. Preconditions: live session; tenant context; the invitation belongs to this tenant. Invariants protected: invitations_terminal_state_chk and identity_invitation_terminal (an accepted or revoked invitation never changes again); a revoked invitation is preserved, never deleted; once revoked, identity_invitation_usable refuses its acceptance. Main path: POST /invitations/{invitationId}/revocation → SELECT ... FOR UPDATE the row in the tenant → UPDATE identity.invitations SET revoked_at = now() → InvitationDeliveryPort.withdraw(context, invitationId), whose adapter dead-letters its still-pending message, if any, with last_failure_class = 'invitation_revoked' and the ciphertext wiped (new SECURITY DEFINER identity.withdraw_invitation_message(uuid), because the runtime cannot update identity.security_outbox_messages); a message already leased is not touched here, and after J1-1b the claim and identity.record_security_outbox_attempt refuse to deliver or requeue a message whose invitation is revoked or expired → audit → 200 with the invitation in state revoked. Adversarial paths:
| Path | Answer |
|---|---|
| No session / foreign tenant / not an administrator | 401 · identity.session_required / 403 · tenant.context_rejected / 403 · identity.action_forbidden (audited as denied) |
| Unknown id, or another tenant's invitation id | 404 · identity.invitation_not_found (RLS makes them the same) |
| Already revoked | 200 with the same invitation; nothing written, no second audit record |
| Already accepted | 409 · identity.invitation_closed; the owner uses O28 or O29 on the member instead |
| Open but expired | 200, revoked (it frees the identifier for a fresh O30) |
| Revocation races an acceptance | Both lock the row; whichever commits first wins, the other gets 409 · identity.invitation_closed |
| The message was already sent | Revocation still wins: the token can no longer be accepted |
Audit: identity.invitation.revoked, target the invitation, change invitation.status before pending and after revoked (as built, the same attribute the J1-2 revocation of an expired invitation records, not invitation.revoked_at), reason revoked_by_member. Idempotency: natural (a revoked invitation answers itself). Plan limit: frees one identity.pending_invitations slot. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /invitations/{invitationId}/revocation | Command | — | Identity | identity.invitations, identity.security_outbox_messages (through the new function), audit.records | Tables: none. New: EXECUTE on identity.withdraw_invitation_message |
O32 — Accept an invitation
Revised for J1.Actor: the invitee, signed in (the one without an identity signs up and confirms the invited identifier first, J1 step 5). Intent: join the organization under the roles and scopes the invitation promised, without ever creating a second organization by mistake (APP_SHELL_AND_ONBOARDING.md). Preconditions: the invitation is open and unexpired; the organization is active; the accepting identity is an active human that owns a verified, non-revoked login_identifiers row with the invitation's (identifier_type, normalized_value); a seat is free. Invariants protected:
- Only a token holder learns anything about an invitation; an unknown token answers the same as any other unknown token (D28).
- The accepting identity proves possession of the invited identifier (
identity_invitation_verified_recipient), so a forwarded or stolen link alone is not enough. - Membership, role assignments, access scopes,
accepted_at/accepted_identity_idand the audit record commit together;role_assignments_scoped_trgandmemberships_seat_limit_trgare deferred and judge the final state at commit. - One current membership per identity and organization (
memberships_identity_organization_current_uq). Main path:
- Preview (step 3 of J1):
POST /invitation-previewswith{ "token": "..." }, session optional. Answers the organization name, the offered role names and scope labels,expiresAt, and the masked identifier, or404 · identity.invitation_not_foundfor an unknown token and409 · identity.invitation_closedfor a closed one. It writes nothing. Read through a newSECURITY DEFINERidentity.preview_invitation(bytea), because the invitee has no tenant context. POST /invitation-acceptanceswith{ "token": "..." }and a session. The token is in the body, never in a path or query string.- The use case hashes the token and calls the new
SECURITY DEFINERidentity.accept_invitation(p_token_hash bytea)(D28). The function takes the caller fromapp.identity_id, never from a parameter, then: locks the invitation bytoken_hashFOR UPDATE; refuses a closed one; refuses an inactive organization; refuses a caller who already has a current membership; insertsidentity.memberships(active); copies eachinvitation_role_assignmentsrow intoidentity.role_assignments(assigned_by= the inviter's identity, when the inviter's membership still exists) and eachinvitation_access_scopesrow intoidentity.access_scopes; re-checks that no copied role grantsorganization.account.manageand that each vertical role is still offerable at its scope (D30); setsaccepted_at = statement_timestamp()andaccepted_identity_id, which fires the recipient check; writes the tenant audit record with the request settings swapped to the new membership, asplatform.record_organization_provisioneddoes; returns the organization id and membership id. - Answer
201with{ organizationId, organizationName, membershipId, roleCodes }. The app selects that organization (O3) and opens it; it never shows provisioning. Adversarial paths:
| Path | Answer |
|---|---|
| No session | 401 · identity.session_required (the preview still works) |
| Token unknown or guessed | 404 · identity.invitation_not_found; identical body for every unknown token |
| Token that is not exactly 43 base64url characters, an unknown field, no token | 400 · foundation.validation_failed; the shape of a token says nothing about any invitation |
| Token of an expired, revoked or already-accepted invitation | 409 · identity.invitation_closed (the holder learns only that this link is dead) |
| Organization suspended or archived since the invitation | 409 · identity.invitation_closed |
| Caller's identity does not own the identifier at all, or owns it unverified | 403 · identity.invitation_recipient_mismatch; detail says to sign in with, or verify, the invited identifier; it never says whether another identity owns it |
| Caller already has a current membership in that organization | 409 · identity.invitee_already_member |
| Same identity retries after a success | 201 with the same organizationId and membershipId, nothing written again |
| Seat limit already used | 409 · identity.seat_limit_reached; the invitation stays open, so it can be accepted after the owner frees a seat (O29) |
| Two identities race with the same token (forwarded link) | Only the one that owns the verified identifier can pass; if both do (impossible: an identifier is verified on one identity), the row lock serializes them and the second gets 409 · identity.invitation_closed |
| Two invitations for the same person (email and phone) accepted concurrently | The second hits memberships_identity_organization_current_uq → 409 · identity.invitee_already_member; its invitation stays open for the owner to revoke |
| The last seat raced by two different invitees | identity.assert_membership_seat_available takes pg_advisory_xact_lock per organization; one commits, the other gets 409 · identity.seat_limit_reached |
| A role's vertical was deactivated (O11) in every business unit the offered scope covers, after the invitation was sent | 422 · identity.invitation_role_vertical_inactive; nothing written, the invitation stays open for the owner to revoke |
| A role on the invitation was deactivated, or made administrative, after it was sent | 422 · identity.invitation_refused; nothing is written and the invitation stays open, so the owner can revoke it and invite again with another role |
| More than 20 previews or acceptances in 10 minutes from one IP | 429 · rate_limit.exceeded |
| Invitee without an identity tries to accept before confirming sign-up | 401 · identity.session_required (no session exists until confirmation) |
Audit: identity.invitation.accepted, actor the invitee, membership the new membership, targets the invitation and the new membership; changes after-only: membership.status, invitation.role_codes, invitation.scope. Refused acceptances write nothing (no tenant to write into, and recording them would reveal token validity to the organization). Idempotency: natural (an invitation accepted by the caller answers the membership it created). Plan limit: identity.active_memberships through the existing deferred memberships_seat_limit_trg. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /invitation-acceptances, POST /invitation-previews | Command, plus a read | The preview is folded in (J1 step 3) | Identity | identity.invitations, identity.invitation_role_assignments, identity.invitation_access_scopes, identity.memberships, identity.role_assignments, identity.access_scopes, identity.login_identifiers, organization.organizations, audit.records, through the new identity.accept_invitation(bytea) and identity.preview_invitation(bytea) | New: EXECUTE on both functions for ninaku_runtime; both SECURITY DEFINER, REVOKE ALL ... FROM PUBLIC |
O33 — Reissue an invitation
Revised for J1.Actor: same as O30. Intent: send the same offer again with a fresh link, when the message was lost, the invitee asks again, or the invitation expired, without retyping the roles and scopes. Preconditions: the invitation belongs to this tenant and is open (expired or not). Invariants protected: token_hash and expires_at are immutable (identity_invitation_provenance), so the old row is revoked, never edited; evidence is preserved, never deleted (J1 finding 4); the old token dies in the same transaction the new one is born. Main path: POST /invitations/{invitationId}/reissue with optional expiresInDays (1 to 30, default 7) → lock the old row → revoked_at = now() → withdraw its pending message (identity.withdraw_invitation_message) → insert a new invitation with the same identifier, a new token, the new expiry and replaces_invitation_id = the old id (new column, below) → copy its role assignments and scopes → enqueue through identity.enqueue_invitation_message (cooldown applies) → audit → 201 with the new invitation. Adversarial paths:
| Path | Answer |
|---|---|
| No session / foreign tenant / not an administrator | 401 / 403 · tenant.context_rejected / 403 · identity.action_forbidden (audited as denied) |
| Unknown id or another tenant's | 404 · identity.invitation_not_found |
| Accepted, or revoked by O31 | 409 · identity.invitation_closed |
| Retry of a reissue that already succeeded (the old id is revoked and its replacement is open) | 201 with the existing replacement, no new message |
| Reissue within one hour of the last message to the identifier | 429 · identity.invitation_rate_limited with Retry-After; nothing is revoked |
| An offered role became inactive or administrative since | 422 · identity.invitation_refused / 403 · identity.invitation_privilege_exceeded; nothing is revoked |
| An offered role's vertical is no longer active in the scope | 422 · identity.invitation_role_vertical_inactive; nothing is revoked |
| Two reissues of the same invitation race | The row lock serializes them; the second sees the replacement and answers 201 with it |
Audit: identity.invitation.reissued, targets the old and the new invitation; changes: invitation.expires_at before/after. As built, the audit contract takes one target per record, so the old invitation is audited as identity.invitation.revoked (reason reissued, invitation.status before and after) and the new one as identity.invitation.reissued (invitation.replaces after-only, invitation.expires_at before the old expiry and after the new one). Idempotency: natural, through replaces_invitation_id. Plan limit: neutral for identity.pending_invitations (one closes, one opens); the daily cap counts it. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
Yes — POST /invitations/{invitationId}/reissue (revoke + insert as one command) | Command | Reuses O30's insert and enqueue path | Identity | identity.invitations plus a new nullable column replaces_invitation_id uuid with a unique index and a same-organization foreign key, needed because nothing links a replacement to the invitation it replaced (natural idempotency and the history both need it); child tables; outbox through the new functions; audit.records | Tables: none. New: the column, both functions' EXECUTE |
13. Authorization contract and policy override
O34 — Authorize an operation (internal contract)
Actor: another module (e.g., Sales, Inventory), never an HTTP caller directly. Intent: ask, in one call, "may this membership do X at this scope?" before executing a command that needs both a permission and (sometimes) an entitlement. Preconditions: tenant context established (O3). Invariants protected: organization-wide RLS alone is not action-level authorization — platform.assert_operation_allowed's own header comment says so explicitly; this case is what makes that distinction real at the application layer, not just in SQL comments. Main path (as built, lot E): the calling module, inside its own tenant transaction, calls OperationAuthorizationContract.decide(context, { permissionCode, capabilityCode, outletId?, verticalCode? }) (src/modules/platform/contracts/operation-authorization.contract.ts). The caller is always the member of the tenant context: there is no membership argument, so nobody can ask on behalf of someone else. The implementation issues exactly one statement, SELECT platform.authorize_operation($1, $2, $3, $4), an invoker function with a pinned search_path that never raises and answers one decision. A malformed request (an empty or longer than 120 characters code, a NUL character, an outlet that is not a UUID) answers permission_missing without reaching the database.
Decision order. Each question is asked only when every earlier one passed, so a caller without the permission never learns anything about the plan, the place or the vertical:
| Order | Decision | Meaning | Public answer (mapped by the consuming module) |
|---|---|---|---|
| 1 | tenant_context_missing | identity.has_active_tenant_context() is false (no context, suspended or ended membership, suspended organization, blocked identity) | The plain refusal of the consuming module; through a route the tenant guard already answers 403 tenant.context_rejected |
| 2 | permission_missing | identity.effective_permission refuses and the member never held a covering assignment | The plain refusal of the consuming module, identical to any other plain denial |
| 3 | scope_unavailable | identity.permission_scope_unavailable: the member holds the permission through a scope whose place or the outlet is inactive | 403 authorization.scope_unavailable, "The place you are assigned to is not available. Contact your administrator." |
| 4 | capability_not_entitled | platform.effective_capability is not enabled (no plan inclusion, a deny override, an expired grant, a lapsed or suspended subscription, an unknown capability) | 403 platform.capability_not_entitled, "Your plan does not include this." |
| 5 | outlet_not_operational | organization.assert_outlet_operational refuses the outlet, an ancestor, its site, brand or business unit | 422 authorization.outlet_not_operational |
| 6 | vertical_inactive | The business unit of the outlet does not run the vertical; a vertical asked without an outlet has no business unit and is always refused (the previous behaviour, kept) | 422 authorization.vertical_inactive |
| 7 | allowed | Every check passed | — |
The outlet, its ancestors and its business unit are resolved on the date of the timezone of the site of the outlet (the previous gate passed current_date). platform.assert_operation_allowed keeps its signature and becomes the raising backstop: it raises 42501 with the constraint platform_operation_<decision> and the decision as hint. The non-raising function is what the application calls: a refusal never aborts the tenant transaction, so the denied audit record of the consuming module commits with it. OperationRefusedError (src/modules/platform/contracts/operation-refusal.ts) lets the consuming module throw after its transaction commits; the consuming module maps the refusal at its own HTTP boundary, and the reference mapping operationRefusalFailure(refusal, plainDenial) (src/modules/platform/presentation/failures/operation-refusal-failure.ts) takes its own plain denial for permission_missing and tenant_context_missing.
Capability catalog and plans (D14). Migration 0071 seeds the global capability codes organization.structure and identity.team and a published Pionero version 3 with the whole catalog, 20 active memberships and 20 pending invitations; version 2 and its offerings retire and no subscription moves by itself. Staff move an organization with the existing POST /platform-admin/organizations/{organizationId}/subscription/plan-changes route. Staff can never assign a trial offering, so a move onto Pionero goes through a private, zero-trial offering of version 3 targeted at the organization (drafted and published with the existing offering routes). Staff administer capabilities and limits through five routes, all POST, staff-only, with a live elevation and an Idempotency-Key, audited only on a real change:
| Route | Answers |
|---|---|
/platform-admin/plan-versions/{versionId}/capabilities | 201 attached now, 200 already attached; 404 platform.plan_version_not_found; 409 platform.plan_version_not_draft; 422 platform.capability_command_refused (unknown or deprecated) |
/platform-admin/plan-versions/{versionId}/capabilities/{capabilityCode}/removals | 204; 404, 409 and 422 as above |
/platform-admin/plan-versions/{versionId}/limits | 201 declared now, 200 changed or the same; 404, 409; 422 unknown or deprecated limit |
/platform-admin/organizations/{organizationId}/capability-overrides | 201 with the override id (grant or deny, reason, optional expiry); 404 platform.organization_not_found; 409 platform.capability_override_conflict while another override of the capability is in force; 422 unknown or deprecated capability or an expiry already past |
/platform-admin/capability-overrides/{overrideId}/revocation | 204 (ending one already ended changes nothing); 404 platform.capability_override_not_found |
A caller who is not active staff is refused with 403 platform.staff_required and one without a live elevation with 403 platform.elevation_required.
No Organization command is capability-gated (D15). A lapsed subscription must never stop an administrator from repairing the structure or the team; the seat limit already follows this rule. The first consumer is proven at the contract level and through a test-only route; the first operational module gates its commands and exposes the refusals over HTTP.
Adversarial / edge cases:
- Caller has the permission but the organization's plan doesn't entitle the capability —
capability_not_entitled, distinctly from a plain permission refusal. - Caller has the permission and entitlement, but the target outlet is itself inactive or has an inactive ancestor —
outlet_not_operational; a member whose only covering scope is the closed place getsscope_unavailable. - A stranger, a suspended member, a member of another organization and an outlet of another organization or an unknown one never get anything but
permission_missingortenant_context_missing.
Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No tenant route; five staff routes administer the catalog | Read (a check, not a mutation) | — | Platform owns OperationAuthorizationContract; Identity keeps PermissionCheckContract for callers that only need the permission piece | platform.authorize_operation(), platform.assert_operation_allowed(), platform.effective_capability(), identity.effective_permission(), identity.permission_scope_unavailable(), five *_as_staff functions | None: ninaku_runtime has EXECUTE on the decision function and the five staff functions, never on the helpers platform.lock_draft_plan_version and platform.capability_id_for_staff |
O35 — Override verification-code / TOTP policy for an organization
Actor: platform ops or an organization administrator (scope TBD by the permission catalog — this is explicitly a forward-looking case, not yet scheduled). Intent: let one organization use different out-of-band-code or TOTP parameters than the platform default, without touching a single use case in Identity. Preconditions: none yet — the override table does not exist in the six canonical files read for this catalog. Invariants protected: D22's forward-looking rule — VerificationPolicyPort is the only door a use case uses to reach these policies (outOfBandCodePolicy(), totpPolicy(), totpPolicyForMethod(stored)); when the override table arrives "junto al módulo organization," it replaces only the port's implementation — no use case changes. Main path (once built): Organization module owns the override table; its infrastructure layer implements VerificationPolicyPort by reading the override for the current organization (falling back to the platform default when none exists) and constructing the same OutOfBandCodePolicy/ TotpPolicy value objects Identity already consumes. Adversarial / edge cases (once built):
- An override changes policy after some authenticators were already enrolled —
totpPolicyForMethod(stored)must keep reconstructing the original policy from the stored method's owndigits/algorithm/period_seconds, exactly as it does today for the global default, so a policy change never locks out prior enrolments. - Override exists but is malformed/incomplete — falls back to the platform default rather than failing every verification for that tenant. Decision:
| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Deferred — no route proposed yet; internal port swap only | Read (policy lookup) | — | Organization owns the future override table; Identity keeps owning VerificationPolicyPort and every use case that calls it | None yet — table does not exist in the schema reviewed | N/A — not built |
Derived API (proposal)
Grouped by family, with the cases each route serves. Every route follows API_AND_SCHEMA_DISCIPLINE.md's task-oriented rule — no raw POST/PATCH/DELETE on a bare noun where the action carries its own invariants (re-parenting, lifecycle transitions, assignment history).
| Group | Routes | Cases served |
|---|---|---|
| Provisioning & context | POST /organizations, GET /organizations, GET /session/context | O1, O2, O4 (O3 is a guard, not a route) |
| Organization profile | PATCH /organization, GET /organization, GET /organization/structure, GET /sites/{id}/outlets | O5, O23 |
| Organization lifecycle (staff only) | POST /platform-admin/organizations/{organizationId}/{suspension,reactivation,archival} | O6 |
| Legal entity | POST /legal-entities, PATCH /legal-entities/{id} | O7, O8 |
| Business unit | POST /business-units, PATCH /business-units/{id}, POST /business-units/{id}/verticals/{code}/{activation,deactivation} | O9, O10, O11 |
| Brand | POST /brands, PATCH /brands/{id} | O12, O13 |
| Site | POST /sites, PATCH /sites/{id} | O14, O15 |
| Outlet | POST /outlets, PATCH /outlets/{id}, POST /outlets/{id}/re-parenting | O16, O17, O18 |
| Sales channel | POST /sales-channels | O19 |
| Assignments | POST /outlets/{id}/legal-entity-assignments, .../business-unit-assignments, GET .../legal-entity-assignments, GET .../business-unit-assignments | O20, O21, O24 |
| Lifecycle | POST /{aggregate}/{id}/{deactivation,reactivation} (one pair per structural aggregate) | O22 |
| Memberships & roles | GET /memberships, POST /memberships/{id}/role-assignments, PATCH /role-assignments/{id}/scope, DELETE /role-assignments/{id}, POST /memberships/{id}/{suspension,termination} | O25–O29 (J1 uses O25, O28, O29) |
| Invitations | GET /invitations, POST /invitations, POST /invitations/{invitationId}/revocation, POST /invitations/{invitationId}/reissue, POST /invitation-previews, POST /invitation-acceptances | O25 (list), O30–O33. Acceptance and preview are addressed by token in the body, never by invitation id: the invitee does not know the id and must not be able to probe ids (D28) |
| Authorization (internal) | no tenant route — OperationAuthorizationContract / PermissionCheckContract; staff: POST /platform-admin/plan-versions/{versionId}/capabilities, .../capabilities/{capabilityCode}/removals, .../limits, POST /platform-admin/organizations/{organizationId}/capability-overrides, POST /platform-admin/capability-overrides/{overrideId}/revocation | O34 |
| Verification policy override | none proposed yet | O35 |
| Published reads (internal) | no route — OrganizationContextContract (businessUnitStates, outletOperatingContext, shareBusinessUnit) and, for Catalog's single-statement hot reads only, the SQL functions organization.outlet_operating_context and organization.business_unit_is_operable (migration 0092; migration 0107 adds the trailing site_timezone column to outlet_operating_context so Catalog can read the local time of an outlet for the hours of its menus, S1; MODULE_CONTRACTS.md §4.4) | CATALOG_OPTIONS_V2.md §6, K10a |
Tenant selection contract. The coordinator's default — every tenant-scoped request carries X-Organization-Id, validated per-request against an active membership (O3) — is the contract this catalog builds against; it matches APP_SHELL_AND_ONBOARDING.md's explicit multi-tab exit criterion ("cada pestaña conserva su propia selección; el servidor valida por solicitud") without any new mutable state. The alternative, POST /organization-selections binding a "current organization" to the session server-side, would need a new mutable column (e.g., on identity.sessions) and would make two tabs on two different businesses fight over one shared session value unless the client also threads a tab-scoped override back in — which defeats the point of making it stateful in the first place. The header contract is recommended and is what every case above assumes.
Implementation plan (staging-sized groups)
Each group closes a self-contained slice of the matrix and is small enough to land on staging on its own (≤ ~400 changed lines per AGENTS.md).
| Group | Cases | Why this boundary |
|---|---|---|
| 0 — grants | none directly | Shipped: migration 0017_runtime_reaches_organization_platform_and_identity_rbac.sql gave ninaku_runtime the organization.* topology, platform.* reads and the identity RBAC and invitation tables (see J1 finding 1). New grants arrive with the slice that needs them. |
| 1 — tenant context foundation | O1, O2, O3, O4 | Everything else needs a real tenant context and a real /session/context; today's implementation is a stub. |
| 2 — organization profile & lifecycle | O5, O6 | Smallest write surface once context exists. |
| 3 — flat catalog entities | O7, O8, O9, O10, O11, O12, O13, O14, O15, O19 | Same shape repeated six times (register + update); no cross-entity invariants beyond per-tenant uniqueness. O11 moves into J1 as slice J1-O11, because the roles an invitation offers depend on it (D30). |
| 4 — outlet & hierarchy | O16, O17, O18 | The one structural aggregate with a real cycle-prevention invariant; worth its own slice for focused review. |
| 5 — effective assignments & structure reads | O20, O21, O23, O24 | Depends on Group 4's outlets existing; introduces the append-only/GiST-exclusion pattern for the first time. |
| 6 — memberships & roles (G-MEMBERS) | O25, O28, O29 for J1; O26, O27 after J1 | J1 slices J1-4 and J1-6 below, then J1-7 for assigning and re-scoping a role of an existing member. |
| 7 — invitations (G-INVITES) | O30, O31, O32, O33 | J1 slices J1-1, J1-1b, J1-R, J1-2, J1-3 and J1-5 below. |
| 8 — authorization contract | O34 | Needed once any consuming module (outside this catalog) starts calling it; can ship right after Group 6. |
| 9 — verification policy override | O35 | Lowest priority; D22 explicitly frames it as "when it arrives," not now. |
J1 slices (G-MEMBERS and G-INVITES, in dependency order)
One aggregate per slice, one PR into staging per slice, each about 400 changed lines or less (an estimate counting SQL, TypeScript, tests and docs; the baseline/inventory regeneration is not counted; a migration and its canonical edit both count). Every slice ships its migration, its canonical SQL edit, its grants and its matrix rows together, under strict TDD. Migration numbers below are relative: staging is at migration 0049 and baseline revision 59, and every slice takes the next free number and revision when it is rebased.
J1-1 as built is too large for one PR. The branch changed about 2,370 authored lines (the migration alone is 710, its canonical copy about 250, the DB contract and concurrency tests about 1,300), not the ~350 planned. It is delivered as J1-1a and J1-1c below, and as migration 0053 (revision 63) the database layer of J1-1 lands with this branch: the outbox carries an invitation, enqueue_invitation_message and withdraw_invitation_message apply the D29 limits, the role check refuses organization.account.manage and the limit definition is seeded. J1-1b's fixes ship as their own change on top of it.
| Slice | Delivers | Size | Proof (kind → what) | Adversarial cases it must include |
|---|---|---|---|---|
| J1-V — launch verticals | Migration: INSERT INTO platform.verticals (code, name, description) ... ON CONFLICT (code) DO NOTHING for hospitality ("Hospitality"), restaurant ("Restaurant") and retail ("Retail"), all active (D30, J1 finding 11); the same rows in 0060_platform.sql. Fixes O1 on a real database | ~100 | DB contract → the three rows exist after db:prepare with status = 'active'; prepare contract → provisioning with hospitality, with restaurant and with retail succeeds without any test-inserted vertical | Re-running the seed changes nothing (idempotent); hotel is still unknown (422 · organization.provisioning_refused); ninaku_runtime cannot insert or update a vertical (42501) |
| J1-1a, J1-1c — invitation delivery schema | As planned before (outbox invitation purpose, challenge_id nullable plus invitation_id, claim/purge reading either expiry, staff reissue refusing invitations, enqueue_invitation_message, withdraw_invitation_message, validate_invitation_role() refusing organization.account.manage, limit identity.pending_invitations, index on (identifier_type, normalized_value, created_at DESC), audit vocabulary, grants), split in two PRs and renumbered | ~400 each | DB contract → every check and function; concurrency → the per-identifier and per-organization advisory locks | Unchanged from the previous plan: both or neither reference; direct insert by ninaku_runtime (42501); a destination other than the invitation's; organization B never superseding A's pending message; claim returning an invitation message with its expiry; purge of expired and revoked invitations; staff reissue refused; the 31st invitation and the second message within an hour refused; the fourth cross-organization message deferred; organization_owner refused |
J1-1b — delivery fixes from the architecture review (migration 0054, revision 64; the replaces_invitation_id immutability rides along) | (1) cross-organization deferral counts only other invitations' messages in pending, leased or published, and never defers past the invitation's validity: available_at = least(next free slot, expires_at - interval '1 hour'), so the cross-organization cap yields to the invitation rather than silently killing it (a refusal is not an option: it would tell organization A that someone else invited the same person, D29); (2) claim_security_outbox_messages and purge_expired_security_outbox_ciphertexts dead-letter (invitation_revoked, invitation_accepted, invitation_expired, one shared identity.invitation_closed_reason) any pending or lease-expired message whose invitation is revoked, accepted or expired, so the claim never leases it; record_security_outbox_attempt turns a retryable_error of such a message into dead_lettered (invitation_revoked / invitation_expired) instead of requeuing it; (3) the claim returns invitation_id and the organization's display name (return type changes, so DROP FUNCTION and recreate, re-grant to ninaku_worker); (4) list_security_outbox_dead_letters_as_staff left-joins challenges and invitations and reports the invitation expiry; (5) enqueue_invitation_message raises 42501 (identity_invitation_inviter_forbidden) unless identity.current_membership_id() holds organization.account.manage organization-wide | ~350 | DB contract → each fix; concurrency → revocation committed while the message is leased, then the lease expires and a second claim runs | Hostile organization B sends three invitations to A's invitee and revokes them: A's message is not deferred; B keeps inviting: A's message is never deferred past expires_at - 1 hour; a revoked invitation whose message is leased is never delivered after the lease expires and is never requeued by a retryable failure; an expired invitation's pending message is never claimed; staff sees an invitation dead letter that the backlog counts; a tenant session without the permission cannot enqueue |
J1-R — roles by vertical (migration 0055, revision 65; built, see "J1-R as built" below) | Migration: identity.roles.vertical_code + roles_vertical_fk to platform.verticals(code) + check roles_custom_without_vertical_chk (organization_id IS NULL OR vertical_code IS NULL); the seven roles of D30 (ON CONFLICT (lower(code)) WHERE organization_id IS NULL DO NOTHING), no role_permissions; new identity.role_offerable_at_scope(p_organization_id, p_role_id, p_business_unit_id, p_legal_entity_id, p_brand_id, p_site_id, p_outlet_id, p_include_descendants) (STABLE, invoker rights, so RLS still confines it to the tenant; the organization is a parameter because identity.accept_invitation calls it before the invitee has a tenant context) implementing the coverage rule; new trigger invitation_access_scopes_vertical_trg on identity.invitation_access_scopes raising 23514 identity_invitation_role_vertical_inactive; EXECUTE on the function for ninaku_runtime. Depends on J1-V | ~350 | DB contract → seed, column, check, function per scope kind; attacker contract → as ninaku_runtime | A waiter offered at a business unit whose restaurant row is inactive is refused; stock_keeper in a restaurant-only organization refused at every scope; an outlet with no effective business unit accepts only neutral roles; organization-wide accepts waiter when any business unit runs restaurant; the seed run twice leaves seven rows; none of the seven has a role_permissions row and identity.effective_permission is false for every seeded permission code for a member holding only them; ninaku_runtime cannot insert, update or delete a system role (RLS, 42501); a custom role with a vertical_code refused by the check; an unknown vertical_code refused by the FK |
J1-O11 — activate or deactivate a vertical (migration 0056, revision 66; built, see "J1-O11 as built" below) | O11 as revised: two routes, use case, repository, audit vocabulary migration (organization.business_unit.vertical_activated, .vertical_deactivated), codes organization.vertical_unavailable and organization.last_vertical_required | ~350 | Unit → use case order and idempotency; integration → repository against PostgreSQL (RLS, triggers); e2e → codes and audit records | Member without the permission (403, audited); foreign or unknown business unit (404); unknown, deprecated or retired vertical, inactive business unit (422); deactivating the last active vertical (409); two concurrent deactivations of the last two verticals leave one; activation repeated and raced (200, one row); reactivation keeps enabled_at |
| J1-2a — offerable roles read (no migration, revision stays 66; built, see "J1-2a as built" below) | GET /offerable-roles (O30) over identity.role_offerable_at_scope | ~250 | Integration → read model against PostgreSQL per scope kind; e2e → codes | Never lists organization_owner, a retired role or another tenant's custom role; foreign scope id (404); scopeId with scopeKind=organization (400); a cashier caller (403); the list and the J1-R trigger agree for every seeded role and scope kind |
| J1-W — the worker delivers invitations | invitation in security-outbox-purpose.ts; ClaimedSecurityOutboxMessage with challengeId nullable and invitationId, mapped in security-outbox-dispatch.repository.ts (today a non-challenge row has no challenge_id and an invitation purpose is mapped as malformed and dead-lettered); copy in security-outbox-message-copy.ts (organization name, link with the token in the fragment, expiry in days; roles are shown by the preview, not the message); invitation link base URL in configuration. Ships before J1-2, so no enqueued invitation meets an old worker. As built: the claimed message is a union, ClaimedChallengeSecurityOutboxMessage (challengeId) or ClaimedInvitationSecurityOutboxMessage (invitationId, organizationName), chosen and validated by the row mapper (a row with neither, with both, or an invitation without its organization name is dead-lettered as malformed_claim_row); the claim's expiry column is read as expiresAt for both kinds. The copy is security-outbox-invitation-copy.ts, English like the four code messages; its subject, text and link are shown in the J1-W invitation message after this table (the remaining time is rounded down, the WhatsApp text is identical without the subject, and the token is percent-encoded in the fragment). No application URL existed, so the worker configuration gained INVITATION_LINK_BASE_URL (an absolute origin with no path, https or local http, validated whenever present; OPTIONAL in every environment, because a required variable would stop every OTP delivery when it is missing; declared on the worker service in .railway/railway.ts as preserve()). When it is absent, development and test fall back to http://localhost:3000, while staging and production log one startup warning naming the variable and treat every invitation message as a retryable failure with the class invitation_link_unconfigured (the token is neither opened nor sent; the normal bounded backoff applies and the message is dead-lettered after the last attempt). The token is opened by the existing OTP opener at send time, lives only in the string passed to the sender, is zeroed after the send and is never logged (the failure logs carry the failure class and the cause name, never a message). The plaintext wipe in SQL is identity.record_security_outbox_attempt (published and failed null the ciphertext, nonce and key version); a claim that returns nothing sends nothing; a retryable failure of a message whose invitation closed is turned into a dead letter by that same function, which the worker cannot observe (its tally counts it as retried). failure class invitation_expired is the worker's own check. Staff: isSecurityOutboxPurpose and the dead-letter response enum accept invitation (OpenAPI major version 4.0.0 because the response enum widened) and the reissue refusal invitation_message maps security_outbox_reissue_invitation_message | ~300 | Unit → mapping and copy; worker integration → a claimed invitation message is sent by email and by WhatsApp and its ciphertext is wiped | A row with neither challengeId nor invitationId is dead-lettered as malformed; the token never appears in a log line; an invitation message whose invitation was revoked before the claim is never sent (J1-1b) |
| J1-2 — invite (O30) (built, see "J1-2 as built" below) | InvitationDeliveryPort (schedule, withdraw) and its outbox adapter; POST /invitations | ~400 | Unit → use case order (permission, roles, vertical rule, member check, open-invitation branch, schedule, audit); integration → repository and adapter against PostgreSQL; e2e → HTTP codes | Every O30 row, including: a manager inviting (403, audited); organization_owner offered (403); stock_keeper offered in a restaurant (422 · identity.invitation_role_vertical_inactive); retired role (422); foreign role and scope (422); already-member (409); non-member and unknown identifier giving byte-identical 201 bodies; different configuration while open (409); exact retry (200, one message); concurrent identical requests (one row, one message); expired-open auto-revoked; pending cap (409); a rollback after schedule leaves no message; token absent from every response, log line and audit record |
| J1-3 — accept (O32) (built, see "J1-3 as built" below) | identity.preview_invitation, identity.accept_invitation (SECURITY DEFINER, audit inside, re-checks privilege and the vertical rule); POST /invitation-previews, POST /invitation-acceptances | ~400 | DB contract → acceptance function (copy, audit, recipient check, seat trigger, vertical re-check); concurrency → last seat raced by two invitees, revoke racing accept; e2e → the full journey for an invitee with an identity, and for one without (sign-up, confirm, accept), ending with the new member selecting the organization (O3) and no second organization existing | Unknown and guessed tokens identical 404; closed (409); mismatched or unverified identifier (403) without revealing the owner; already a member (409); retry after success (201, same membership); seat full (409, invitation still open); organization suspended (409); deactivated role (422); vertical deactivated after sending (422 · identity.invitation_role_vertical_inactive); Throttler (429) |
J1-4 — who is in (O25) (migration 0059, revision 69; built, see "J1-4 as built" below) | GET /memberships with meta.seats, GET /invitations, identity.invitation_delivery_states | ~300 | Integration → read models, RLS isolation, keyset cursor; e2e → codes and the seat numbers after J1-3 | Cashier listing (403); another tenant's members never returned, including with a forged cursor; tampered cursor (400); token_hash never in the body; seatLimit null when unlimited; each assignment shows its role code and verticalCode |
J1-5 — revoke and reissue (O31, O33) (migration 0060, revision 70; built, see "J1-5 as built" below) | invitations.replaces_invitation_id (the column existed since migration 0053; 0060 adds its unique index and the audit action); POST /invitations/{invitationId}/revocation, POST /invitations/{invitationId}/reissue, both through InvitationDeliveryPort | ~350 | DB contract → new column and its foreign key; integration → revoke/reissue repository; concurrency → reissue racing accept, two reissues racing; e2e → codes and the old link refused after reissue | Revoke accepted (409); revoke twice (200, one audit record); revoked invitation's pending message withdrawn; reissue accepted or revoked (409); reissue retry (201, same replacement, one message); reissue inside the cooldown (429, nothing revoked); reissue whose vertical was deactivated (422, nothing revoked); old token answers 409 · identity.invitation_closed |
J1-6 — control (O28, O29) (migration 0061, revision 71; built, see "J1-6 as built" below) | DELETE /role-assignments/{roleAssignmentId}, POST /memberships/{membershipId}/suspension, POST /memberships/{membershipId}/reactivation, POST /memberships/{membershipId}/termination (one aggregate: membership authority; the audit vocabulary is the only SQL) | ~400 | Integration → D21 paths; concurrency → two administrators removed at once (identity.lock_administration_tenant); e2e → codes, the seat freed by a suspension lets a waiting acceptance through | Last organization-wide administrator (23514 · identity_last_administrator_required, answered 409 · identity.last_administrator_required); reactivation over the seat limit (409 · identity.seat_limit_reached); a member suspending themselves while last administrator; tenant audit records for each change |
J1-7 — assign and re-scope a role (O26, O27) (migration 0062, revision 72; built, see "J1-7 as built" below) | POST /memberships/{membershipId}/role-assignments, PATCH /role-assignments/{roleAssignmentId}/scope; the trigger access_scopes_vertical_trg and the audit actions identity.role_assignment.created and identity.role_assignment.scope_changed | ~700 | DB contract → vertical backstop, D21 on a scope change, cross-tenant; concurrency → vertical deactivation and two administrators narrowing each other; integration → repository; e2e → codes, the journey | An administrator role never given or widened over HTTP; a vertical role at a unit without its vertical; the last administrator narrowing their own scope; two administrators narrowing each other; a suspended member; another organization's membership, assignment, role or scope target |
The J1-W invitation message
The placeholders in angle brackets are filled at send time; the expiry is one of N days, 1 day or less than a day.
text
Subject: <organization> invited you to join Ninaku
<organization> invited you to join their team on Ninaku. Open this link to accept: <link> The invitation expires in <expiry>.
Link: <INVITATION_LINK_BASE_URL>/join#token=<token>J1-R as built. The slice is the database layer only. The column and the check live in 0040_identity.sql; the foreign key and the seven-role seed live in 0060_platform.sql right after the vertical seed (a 0040 foreign key to platform.verticals cannot exist before the platform schema); the coverage function, the scope trigger and the delete guard live in 0400_cross_domain_contracts.sql, where the other cross-domain contracts sit. identity.role_offerable_at_scope is plpgsql with invoker rights, and ninaku_runtime holds EXECUTE on it (runtime_grants.sql, checked by the prepare contract), because the scope trigger runs as the caller. Two things go beyond the plan row: the trigger takes FOR SHARE locks on the organization's active rows of the role's vertical, in bv.id order (the same key as the deactivation of J1-O11), before it decides, so a deactivation that commits first refuses the offer and one that arrives later waits for it (an invitation can still outlive a later deactivation, which the acceptance re-check of J1-3 catches); and roles_system_role_protect_trg refuses DELETE of any role with a null organization_id (23514 · identity_system_role_undeletable), as D30 says a system role is only ever inactive or archived. The trigger names identity_invitation_role_vertical_inactive for every refusal of the function (inactive role, foreign role, administrative role), not only a vertical one; the earlier triggers still refuse those first with their own names. Not proven here: ninaku_runtime inserting, updating or deleting a system role (the row stays a gap).
J1-O11 as built. One use case, ChangeBusinessUnitVerticalUseCase (activate and deactivate, sharing one permission-then-lock preamble so the two commands do not duplicate it), its repository and port, the two routes in BusinessUnitVerticalsController, the two failures and migration 0056 (audit vocabulary only; the table, its trigger, its policy and its runtime grants already existed). The activation answers 201 for a new row and 200 otherwise; the response is { businessUnitId, verticalCode, status }. Beyond the plan row: the last-vertical rule is per organization; the audit change is one attribute per vertical, organization.business_unit.vertical.<code>, with before and after status; a deactivation on an inactive business unit is refused like an activation; the lock on the organization's active rows is taken only when a real deactivation is about to happen. Interaction with J1-R: the invitation_access_scopes_vertical_trg trigger took FOR SHARE OF bv without an ORDER BY while a deactivation locks in id order; with the heap order of the rows the reverse of the id order, an organization-wide offer and a deactivation deadlocked (40P01, observed on the first round against the first version of migration 0055). The trigger now locks ORDER BY bv.id, the same global key as the deactivation, and the test NINAKU_ROLES_BY_VERTICAL_CONCURRENCY_OK: an organization-wide offer and the id-ordered deactivation lock never deadlock when the heap order is the reverse of the id order passes 20 rounds with both sessions completing; removing the ORDER BY from the function makes it fail again. The migration is named 0056_business_unit_vertical_audit_actions. Not proven here: that a deactivation leaves the role assignments of the vertical's roles untouched (the row stays a gap: O26 exists since J1-7, but no test deactivates a vertical under a member who holds its role yet).
K10c as built (no migration). Organization no longer names a Platform table: ChangeBusinessUnitVerticalUseCase keeps the permission, the unit lock, the unit status check and the audit record, and calls BusinessUnitVerticalsContract.activate and .deactivate inside the same tenant transaction. Platform (ChangeBusinessUnitVerticalStatusUseCase and PlatformBusinessUnitVerticalsRepository) owns the catalog check, the status read, the insert or update, the organization advisory lock and the FOR UPDATE of the active rows in id order, now filtered by organization.business_unit_is_operable(...) instead of a join on organization.business_units, and every statement names the organization in context explicitly. The contract answers an outcome (changed, unchanged, unavailable, last_active_vertical) and the previous status, and Organization turns the refusals into the same 422 and 409 as before. The statements per request are the same on every success path; an activation on an inactive or archived unit is refused before Platform reads the catalog, one statement fewer.
J1-2a as built. No SQL: the repository selects from identity.roles with identity.role_offerable_at_scope(...) in the WHERE clause, so the offering rule exists only in the database and the list cannot disagree with the invitation trigger (ORDER BY name, id). The route lives in the Organization module, not in Identity as the J1 plan row implies: the failure catalogue of Identity is tested to carry only identity.* codes, and the read is a tenant-scoped read of the organization's own structure, so it answers the codes of the structure routes (403 · organization.action_forbidden, not identity.action_forbidden; the invite route of J1-2 keeps the Identity codes the plan names). Query: scopeKind (organization, business_unit, legal_entity, brand, site, outlet), scopeId (a UUID, forbidden for organization, required for the others) and includeDescendants (true or false, default false, accepted as true only for an outlet, like the access_scopes_descendants_chk constraint); anything else is 400. The use case checks organization.account.manage organization-wide first (no audit record: a read), then the existence of the target, which RLS confines to the caller's organization (a foreign id and an unknown id are the same 404, and a caller without the permission is refused before the lookup, so the answer reveals nothing about the scope). The permission check is not mayManageOrganization, which audits the denial; reads write nothing. A business unit that exists but is inactive is not refused: it lists only neutral roles, because the SQL function treats it as running no vertical. The answer is 200 { data: [ { roleId, code, name, verticalCode } ], meta }, Cache-Control: private, no-cache, no ETag (turning a vertical on or off changes the list without changing any row of the roles, so no version or hash of the data the roles come from would be a safe validator) and no pagination (the catalog of one organization is at most a few dozen roles). Another organization's role never reaches the list through two layers: the identity.roles select policy hides it, and the function's own organization clause refuses it; the proof removes both together, because removing one alone is absorbed by the other.
J1-2 as built. Migration 0057 (revision 67) seeds only the audit vocabulary (the resource kind identity.invitation and the actions identity.invitation.created and identity.invitation.revoked, owner identity, tenant scope, retention class administration.platform); the writes are plain SQL from the repository, because ninaku_runtime already held the table grants and EXECUTE on identity.enqueue_invitation_message and identity.withdraw_invitation_message, and no new function was needed. The use case is InvitePersonUseCase in the Identity module, with InvitationRepositoryPort, InvitationDeliveryPort (schedule, withdraw; adapter SecurityOutboxInvitationDeliveryService) and InvitationTokenPort (32 random bytes, base64url; token_hash = sha256 of that string, the form J1-3 will hash again). The body is the one of the O30 main path (roles, one to five items, each roleId plus scope; the scope carries id and, for an outlet only, includeDescendants); a phone must be an international E.164 number, because there is no country field. Natural idempotency, as O30 says (the neighbouring structure commands do not take an Idempotency-Key either): the same configuration answers 200 with the open invitation and no second message, a different one answers 409 · identity.invitation_already_open. Beyond the plan row: (1) before it reads the open invitation the repository takes the advisory lock identity.invitation_open:<organization>:<type>:<normalized value>, so two identical requests never reach the unique index (the second waits, then answers 200); (2) the vertical rule is enforced only by the J1-R trigger, so the member check and the open-invitation branch run before it and a refusal rolls the whole transaction back, including a revocation of an expired invitation made earlier in it; (3) the privilege check is audited as a denial whose target is the id the invitation would have had; (4) the revocation of an expired invitation also calls withdraw and is audited with reason expired_replaced; (5) the audit of the creation carries the identifier type, the masked identifier (from the normalized value, so a retry masks alike), invitation.role_codes, one invitation.scope.<role code> per role (organization, <kind>:<id> or <kind>:<id>:descendants) and the expiry; (6) every role refusal that is not a privilege refusal (unknown, retired or foreign role) and every scope target that is unknown, inactive or foreign answer 422 · identity.invitation_refused with one detail, because Identity cannot emit the organization.structure_not_found of the structure routes. The 429 · identity.invitation_rate_limited carries Retry-After from the retry_after_seconds hint of the per-address hour limit; the organization daily cap carried no hint when this slice shipped and gained one in J1-3 (migration 0058, see "J1-3 as built"). PublicFailureError gained an optional retryAfterSeconds for this, and the failure catalogue test now allows retryable: true for a 429. INVITES_A_PERSON_RATE_LIMIT (20 requests per 10 minutes per IP) guards the route. A message deferred by the cross-organization cap answers the same 201 and only carries a later available_at (proven over HTTP). Not covered by a test: the trace attributes (no code path sets one) and response-time equality for the no-enumeration rule.
J1-3 as built. Migration 0058 (revision 68) adds the audit vocabulary (the resource kind identity.membership and the action identity.invitation.accepted, tenant scope, owner identity), the two functions and a fix of J1-2: the daily organization cap of identity.enqueue_invitation_message now raises with retry_after_seconds=<seconds until the oldest invitation of the window leaves it, at least 1>, and invitation-failure.ts reads it, so that 429 carries Retry-After like the per-address one. Routes: POST /invitation-previews answers 200 ({ organizationName, identifierType, identifier (masked), expiresAt, callerOwnsIdentifier, roles: [{ code, name, scope: { kind, name, includeDescendants } }] }) and POST /invitation-acceptances answers 201 ({ organizationId, organizationName, membershipId, roleCodes }, the same for a replay). Both take { token } strictly (43 base64url characters), both use ANSWERS_A_CHALLENGE_RATE_LIMIT, neither takes an invitation id or an X-Organization-Id. Failures: 404 · identity.invitation_not_found, 409 · identity.invitation_closed, 403 · identity.invitation_recipient_mismatch, 409 · identity.invitee_already_member, 409 · identity.seat_limit_reached, 422 · identity.invitation_refused and 422 · identity.invitation_role_vertical_inactive, each from a named constraint of the function, mapped only in invitation-acceptance-failure.ts. What the preview reveals, and to whom: only to the holder of the token, the organization name, each offered role with its name and the kind and name of its scope, the expiry, the masked identifier (masked in the use case) and callerOwnsIdentifier, which is about the caller only (false with no session, with another identity or with the identifier unverified), so it never says whether someone else owns the identifier; a closed invitation (expired, revoked, accepted, or its organization not active) answers one row with no detail and the use case 409; an unknown hash answers no row and 404. The preview function is STABLE, so the database itself refuses a write inside it. A new OptionalSessionGuard (src/foundation/auth) lets the preview work with or without a session: no Authorization header means nobody, but a header that is present and not a live bearer session is 401 · identity.session_required, never taken for nobody. The acceptance is one call of identity.accept_invitation inside one system transaction (SystemTransactionRunnerService, the caller sets app.identity_id and app.actor_identity_id, the function reads identity.current_identity_id() and has no identity parameter). Order inside the function: lock the invitation by token_hash FOR UPDATE (unknown: identity_invitation_not_found); an invitation accepted by the same caller answers the membership whose joined_at equals accepted_at (the membership is created with joined_at = accepted_at = statement_timestamp(), which is how a replay finds it) and writes nothing; accepted by anyone else, revoked, expired or of an inactive organization: identity_invitation_closed; caller not an active human owning the verified, non-revoked identifier: identity_invitation_recipient_mismatch (42501); a current membership of the caller: identity_invitee_already_member; then, for every offered role, status active, global or of the organization and not granting organization.account.manage (identity_invitation_role_unavailable, mapped to 422 · identity.invitation_refused) and identity.role_offerable_at_scope at each of its scopes (identity_invitation_role_vertical_inactive), after taking FOR SHARE on the vertical rows in bv.id order like the J1-R trigger. Only then the membership, the seat check, the role assignments and scopes (assigned_by and granted_by the inviter's identity when that membership still exists), the accepted_at update (which still fires the recipient trigger) and the audit record. Nothing is written for a refusal, so every refusal leaves the invitation open, and the use case needs no compensation. The tenant audit record is written with the four request settings swapped to the new membership and restored from the values read at entry, exactly as platform.record_organization_provisioned does; the record carries the invitation and the membership as targets and never the token or its hash. The seat limit is judged by identity.assert_membership_seat_available, the same function the deferred memberships_seat_limit_trg calls, which the function now calls itself right after inserting the membership: the trigger would raise at COMMIT, outside the repository's try, where no constraint mapping can catch it; with the call inside the function the refusal is raised (identity_membership_seat_limit, 409 · identity.seat_limit_reached) with the invitation untouched, and the trigger stays as the fence at commit. The refusal for a role that is no longer offerable is the one this document already listed for O32 (422, invitation left open for the owner to revoke). Differences from the plan row: a malformed token is 400, not 404 (the shape of a token is public and says nothing about any invitation); the response of the acceptance carries the organization name and the role codes besides the two ids; the preview answers 200. Not covered by a test: an archived organization separately from a suspended one (one status check), the phone path of the preview and the acceptance (only email is exercised over HTTP), response-time equality for the unknown and closed answers, and the OPENAPI_DOCUMENT_VERSION stays 4.0.0 because both routes are additive.
J1-4 as built. Migration 0059 (revision 69) adds the function identity.invitation_delivery_states(uuid[]) and the index invitation_access_scopes_role_assignment_idx on identity.invitation_access_scopes (invitation_role_assignment_id). The function is STABLE, SECURITY DEFINER, pins search_path = pg_catalog, pg_temp with row_security = off, returns only (delivery_invitation_id, delivery_publication_state) of the newest message (created_at DESC, id DESC) of each invitation it is given, and does so only when identity.has_active_tenant_context() and only for invitations whose organization_id is identity.current_organization_id(), because with row security off the function has to be the tenant fence itself; EXECUTE goes to ninaku_runtime and to nobody else. The index exists because EXPLAIN (ANALYZE, BUFFERS) over 2,500 invitations of one organization (6,500 in the table) showed a Seq Scan of invitation_access_scopes for every invitation of the page (14.6 ms for 50 invitations) and shows an Index Scan with it (0.4 ms); the partial unique indexes of that table cannot serve the join. Routes: GET /memberships and GET /invitations (Identity, tag Memberships and Invitations) with X-Organization-Id, SessionGuard and TenantContextGuard, permission organization.account.manage checked organization-wide inside the tenant transaction, so a refusal is 403 · identity.action_forbidden (the new MemberDirectoryForbiddenError, mapped beside the invitation one), nothing is audited, and the answer carries Cache-Control: private, no-cache and no ETag. The cursor binding is the route, the organization and the filter, so the cursor of another organization or of another status or state is 400 · collection.cursor_incompatible. Seats: meta.seats.used is one count(*) of the memberships of the organization with status = 'active', the same set identity.assert_membership_seat_available counts (a suspended or an ended membership does not count), and limit is platform.effective_limit(org, 'identity.active_memberships') read in the same statement, null when it is unlimited or the limit was never defined. A members page is four statements (the page, the role assignments with their scopes and roles, the identifiers, the seats) and an invitations page three (the page, the offered roles, the delivery function). The member query repeats organization_id = $1 although the policy also confines it: memberships_select_policy lets a caller read their own memberships of other organizations, so without the filter a caller who belongs to two organizations would see their other membership in this list (a test pins it). Differences from the plan row and from the text of O25 above: the identifier of an invitation is masked, not "as stored"; the default invitation filter is open,expired and the filter takes one state; deliveryState has the four values above instead of the raw outbox states; a role assignment shows code, name and verticalCode (the plan row's code and verticalCode) and its roleAssignmentId, which J1-6 needs; the member's identifiers are shown as stored, to an administrator of the organization that person belongs to. No index was added to identity.memberships for the keyset: with the policy's OR the planner reads the organization's rows with a bitmap scan and a top-N sort (1.5 to 3 ms for 2,500 members, no sequential scan), and a (organization_id, joined_at DESC, id DESC) index tried in the same measurement did not remove the sort. ApiCollectionResponse gained an optional meta argument to document meta.seats, and the private, no-cache header value became the shared TENANT_CACHE_CONTROL. Not proven: the phone identifier of a member over HTTP, and the latency of the two routes under the SLA percentiles (they are administrative and are not in perf:http). OPENAPI_DOCUMENT_VERSION stays 4.0.0 because both routes are additive.
J1-5 as built. Migration 0060 (revision 70) adds the unique partial index invitations_replaces_invitation_uq on identity.invitations (replaces_invitation_id) WHERE replaces_invitation_id IS NOT NULL and seeds the audit action identity.invitation.reissued (tenant scope, owner identity, before and after allowed). The column and its same-organization foreign key already existed (0053), so the plan row's "migration" is only the index: an invitation is replaced at most once (a second replacement is refused even for another address, 23505 · invitations_replaces_invitation_uq, which the repository maps to 409 · identity.invitation_closed) and the replacement is found by an index instead of a scan of a table that grows without limit. No function was needed: identity.withdraw_invitation_message and identity.enqueue_invitation_message already served the runtime. The description of the action identity.invitation.revoked still says "revoked because it expired and a new one replaces it", because audit.action_types has an update guard; the reason code tells the three causes apart (expired_replaced, revoked_by_member, reissued). Routes (Identity, tag Invitations, X-Organization-Id, SessionGuard, TenantContextGuard, the per-IP INVITES_A_PERSON_RATE_LIMIT, permission organization.account.manage organization-wide, a refusal audited as denied): POST /invitations/{invitationId}/revocation answers 200 with { invitationId, identifierType, identifier (masked), roles, status: "revoked", expiresAt } and takes no body; POST /invitations/{invitationId}/reissue takes an optional strict body { expiresInDays } (1 to 30, default 7) and answers 201 with the shape of POST /invitations (status: "pending"). Codes: 400 · foundation.validation_failed (id not a UUID, bad body), 401, 403 · tenant.context_rejected, 403 · identity.action_forbidden, 404 · identity.invitation_not_found (unknown id and another organization's id, the same answer, because the row policy hides the foreign row), 409 · identity.invitation_closed (accepted; for a reissue also revoked without an open replacement), 429 · identity.invitation_rate_limited with Retry-After, 422 and 403 of the offer at reissue, and 409 · identity.invitation_pending_limit_reached as at invitation. Locking: both routes call lockById, which reads the identifier of the row, takes the same advisory lock the invite flow takes on (organization, type, address) and then SELECT ... FOR UPDATE the row, in that order, so invite, revoke and reissue of one address never deadlock and serialise. identity.accept_invitation locks the same row FOR UPDATE; under READ COMMITTED the second locker re-reads the row after the first commits, so a revoke and an acceptance racing end with exactly one winner: if the revoke commits first the acceptance answers 409 · identity.invitation_closed; if the acceptance commits first the revoke reads accepted_at and answers 409 without writing, and the membership exists. Two administrators revoking at once change the row once (the second reads it revoked, answers the same 200 and writes and audits nothing); two reissuing at once give both 201 with the same replacement, because the second reads the old row revoked and finds the replacement by replaces_invitation_id. Revoke: lock, refuse accepted, no-op if revoked, else revoked_at = now(), withdraw the message and audit with reason revoked_by_member. Reissue, in one transaction: lock, refuse accepted, answer the open replacement if the old row is revoked (else 409), re-check the offer (examineOffers, the same function the invite flow uses: role active, not administrative, scope targets active), revoke the old row, withdraw its message and audit it (reason reissued), insert the new row with the same roles and scopes, a new 32-byte secret (hash only), replaces_invitation_id and an expiry counted from the reissue, seal and enqueue the secret through InvitationDeliveryPort and audit identity.invitation.reissued. The D29 limits apply unchanged because the same identity.enqueue_invitation_message runs: the cooldown counts the old message (dead-lettered messages still count), so a reissue within an hour of the previous message is 429 and the whole transaction rolls back (nothing revoked, no audit); a vertical turned off since is refused by the J1-R trigger at the insert with the same rollback. A retry of a reissue is naturally idempotent through the link (201 with the existing replacement while it is open; if it was closed since, 409). The old outbox message is dead-lettered as invitation_revoked with the ciphertext wiped (a leased message is closed by the claim rules of 0054); the new one is pending. Shared code: invitation-events.ts (permission, audit helpers), invitation-offers.ts, issue-invitation.ts and revoke-open-invitation.ts are used by the invite, revoke and reissue use cases; the J1-2 wiring moved to invitation-sending.providers.ts. Not proven: the hourly cooldown by waiting an hour (the message is aged by a SQL fixture, backdateMessage, because no route can age a message), a role deactivation or archival (no route exists, so UPDATE identity.roles is the fixture), the accepted state of an invitation in the race test is reached through the real acceptance route, and a leased message of a revoked invitation over HTTP (covered at the database by the 0054 contracts). OPENAPI_DOCUMENT_VERSION stays 4.0.0 because both routes are additive.
J1-6 as built. Migration 0061 (revision 71) only seeds the audit vocabulary: the resource kind identity.role_assignment and the tenant action types identity.membership.suspended, identity.membership.reactivated, identity.membership.ended and identity.role_assignment.revoked (owner identity, administration, medium risk, before and after allowed). There is no new function and no new grant: ninaku_runtime already updated identity.memberships and deleted identity.role_assignments and identity.access_scopes, and the triggers that protect the last administrator (D21) and the seat limit already existed. Routes (Identity, tags Memberships, X-Organization-Id, SessionGuard, TenantContextGuard, organization-wide organization.account.manage, a refusal audited as denied with the reason permission_missing): POST /memberships/{membershipId}/suspension, /reactivation and /termination take no body and answer 200 with { membershipId, status, suspendedAt, endedAt }; DELETE /role-assignments/{roleAssignmentId} answers 200 with { roleAssignmentId, membershipId, roleCode }. Codes: 400 · foundation.validation_failed (id not a UUID), 401 · identity.session_required, 403 · tenant.context_rejected, 403 · identity.action_forbidden, 404 · identity.membership_not_found or identity.role_assignment_not_found (unknown and of another organization are indistinguishable), 409 · identity.last_administrator_required (new), 409 · identity.seat_limit_reached (reactivation over the limit; its text no longer speaks only of invitations), 409 · identity.membership_ended (new: ending is final, so suspending or reactivating an ended membership is refused; the person comes back through a new invitation, which creates a new membership, proven through the real flow) and, for a member removed while their own request was running, the tenant refusal. A state that already is the requested one answers 200 and writes nothing and leaves no second audit record; a revoked role assignment is gone, so repeating the call answers 404.
The last administrator (D21). The rule is the database's: nobody can revoke the last organization-wide organization.account.manage assignment, or suspend or end the last active member holding it, caller included; a suspended or ended administrator does not count. The repository answers it at the moment of the change: the audit record is written first, then SET CONSTRAINTS ALL IMMEDIATE runs the deferred checks (the audit completeness check must run while the caller is still an active member, or a member removing themself would make their own audit record invisible to it) and the change follows, so the refusal is raised inside the repository call and mapped by constraint name (identity_last_administrator_required, identity_membership_seat_limit) there and nowhere else. Two administrators removing each other at the same moment leave exactly one: the use case takes the per-organization administration lock (pg_advisory_xact_lock on identity.admin.tenant:<organization>, the very key identity.lock_administration_tenant takes in the triggers) before it reads anything, and re-asserts the tenant context under it, so the second request waits, finds itself removed and gets the tenant refusal, or finds itself still an administrator and meets the database's check. The race is proven at the database (member-control-concurrency.ts), at the repository (two transactions, one held until the other waits) and over HTTP (three rounds, exactly one 200). A request from a member whose membership ended while it was in flight may therefore answer 403 · tenant.context_rejected or 404 instead of 409; exactly one request wins in every case.
What a removed member keeps. The tenant context is read from the membership on every request (ResolveTenantContextUseCase and the row-level security function identity.has_active_tenant_context), so a suspended or ended member is refused at their very next request with the same 403 · tenant.context_rejected a stranger gets, and GET /session/context shows no selected organization. Their sessions and tokens are not revoked: the person still has an account and may belong to other organizations. Suspension keeps the roles and the seat is free; reactivation takes a seat again (the deferred seat check, identity_membership_seat_limit, now settled inside the repository), so a full team refuses it until a seat frees.
Not proven or not built. The second administrator of the tests is a SQL fixture (assignSystemRole): no route promotes a member and an invitation cannot offer an administrative role by design (O26, built later in J1-7, refuses it too). The seat limit of the full-team tests is a SQL override (limitSeats): no route changes a plan limit. The members without a role of the access tests are SQL fixtures too (aMemberWithoutRole), as in the J1-5 tests. Repository integration specs seed their people with SQL by nature; the e2e specs and the journey use only the routes (provisioning, sign-up and verification, invitation, acceptance, listing, revocation, suspension, reactivation, termination, vertical activation). The re-invitation of an ended member waits an hour for the same address, so the earlier message is aged by backdateMessage. OPENAPI_DOCUMENT_VERSION stays 4.0.0: the three paths are additive.
The journey is complete. After J1-6 the owner of a new restaurant can provision the organization, see the roles they may offer, invite a manager, a waiter and a cashier, bring in the one who has no account, read the team, the invitations and the seats, revoke a wrong invitation and invite the right address, suspend a member and see the seat freed, end a membership and see that person lose access, take a role away, and activate another vertical and see its roles appear; members-control-journey.e2e-spec.ts walks all of it over HTTP and checks what the owner sees at every step. O26 and O27 were built afterwards as J1-7.
J1-7 as built. Migration 0062 (revision 72) adds the database backstop of the vertical rule and the audit vocabulary. The function identity.validate_access_scope_vertical and the trigger access_scopes_vertical_trg on identity.access_scopes refuse, with identity_role_assignment_vertical_inactive, a role whose vertical_code is not null at a scope where identity.role_offerable_at_scope says its vertical is active in no covered business unit, as invitation_access_scopes_vertical_trg already did for invitations. A role with no vertical, such as the administrator role stored by provisioning, passes unchanged, which is why the trigger checks the vertical column and does not call the coverage function for every role (the function also refuses administrative roles, which provisioning stores). The vocabulary gains the tenant action types identity.role_assignment.created and identity.role_assignment.scope_changed. No new grant: ninaku_runtime already inserted into identity.role_assignments and identity.access_scopes and deleted from identity.access_scopes, and the row-level security policies already use the scalar subquery form. No new SECURITY DEFINER function: the changes are plain statements under row-level security, like J1-6.
Routes (Identity, tag Memberships, X-Organization-Id, SessionGuard, TenantContextGuard, organization-wide organization.account.manage, a refusal audited as denied with the reason permission_missing). POST /memberships/{membershipId}/role-assignments takes { roleId, scope }, the scope shaped exactly like the one of an invitation (organization, business_unit, legal_entity, brand, site or outlet with includeDescendants), and answers 201, or 200 for the same role at the same scope (nothing written, no second audit record). PATCH /role-assignments/{roleAssignmentId}/scope takes { scope } and answers 200; the same scope again is 200 with nothing written. Codes: 400 · foundation.validation_failed, 401 · identity.session_required, 403 · tenant.context_rejected, 403 · identity.action_forbidden, 403 · identity.role_assignment_privilege_exceeded (new), 404 · identity.membership_not_found or identity.role_assignment_not_found (unknown and of another organization are indistinguishable), 409 · identity.role_already_assigned (new: the same role at another scope; change its scope instead), 409 · identity.membership_not_active (new: the member is suspended or ended), 409 · identity.last_administrator_required, 422 · identity.role_assignment_refused (new: unknown, retired or foreign role or scope target, all alike) and 422 · identity.role_assignment_vertical_inactive (new).
Decisions where the text was silent. The order is the one of J1-6: the per-organization administration lock first, then the permission, then the checks, then the audit record and the change. A refusal for a role that grants administration is audited as denied with the reason privilege_exceeded, as in the invitation. O27 applies the privilege rule only when it would widen an administrator role to the whole organization: narrowing an administrator is allowed (D21 guards the last one) and a role already at the scope asked answers 200 before the rule is read, so an administrator is never refused for the scope they have. The role of a suspended or ended member cannot be re-scoped (409 · identity.membership_not_active), consistent with O26. The vertical rule is checked twice, on purpose: the use case asks identity.role_offerable_at_scope for roles with a vertical (so the answer is a clean 422) and the trigger is the backstop for any other writer. The audit record is written before the change, in the same transaction, so a refused change leaves no record of a change.
The scope change (O27). The repository settles the deferred checks, holds them, deletes the scope row, inserts the new one and settles again, in one transaction, never an in-place update, so the deferred role_assignments_scoped_trg and D21 (identity_last_administrator_required) fail inside the call and are mapped by constraint name there and nowhere else. Two administrators narrowing each other at the same moment leave exactly one: the use case takes the same lock the triggers take (identity.admin.tenant:<organization>), so the second request waits and then is refused either by the permission check (it was narrowed) or by the database. The race is proven at the database (role-assignment-concurrency.ts), at the repository (two transactions, one held) and over HTTP.
Not proven or not built. The administrator of the demotion test, the second administrator of the race test, the members without a role and the member who administers a single business unit are SQL fixtures (grantManageRole, assignSystemRole, aMemberWithoutRole): no route creates an administrator or a scoped administrator by design. The repository integration specs seed their people with SQL by nature (addTeamMember); the e2e specs and the journey use only routes (provisioning, sign-up, invitation, acceptance, listing, revocation, suspension, termination, business unit, site and vertical activation), apart from the two fixtures named. A vertical role at a site, brand, legal entity or outlet cannot be given over HTTP yet because no route opens an outlet, so the vertical rule is proven over HTTP at the whole organization and at a business unit, and at the other kinds in the database contract. Over HTTP the effect of a changed scope on access is visible only for the one permission that exists, organization.account.manage: the seven system roles have no permission, so narrowing a cashier changes what the member list shows, not what the cashier can do. identity.effective_permission does not check that the scoped site, brand, business unit, legal entity or outlet is active, so a role scoped to a deactivated entity would still grant access; the matrix records it as a gap to decide when O22 is built. OPENAPI_DOCUMENT_VERSION stays 4.0.0: the two paths are additive.
Order: J1-V ships first and alone (it repairs O1). J1-1a → J1-1c → J1-1b; J1-V → J1-R; J1-O11 needs nothing new (its tests seed verticals) but must merge before J1-2a. J1-2a needs J1-R and J1-O11; J1-W needs J1-1b; J1-2 needs J1-2a and J1-W. J1-2 blocks J1-3, J1-4 and J1-5; J1-6 needs only J1-3 (a second member to act on). The journey is usable by the owner after J1-4 and complete after J1-6; J1-7 follows it.