# Journey: Tenant onboarding (admin invites first member)

The shipped browser path covers operator invite creation, a one-time raw token,
signed-in acceptance, durable seat/member state, and a link to onboarding. It
does not send an email, perform invite-specific SSO/MFA, run the ten-step
wizard, record first login/action, or reconcile live KPIs. “Tenant onboarding”
is therefore a partial operator→member hand-off, not a completed first-use
funnel.

## Personas

- **Operator** — opens `/operator/tenant` and creates one pending invite.
- **Invitee with an Oshun session** — receives the token out of band and accepts
  it while already authenticated.
- **Tenant administrator** — needs tenant-scoped authorization and live seat/
  member truth rather than the fixed operator fixture.
- **First-time member** — should continue through onboarding and a first domain
  action, but that continuation is not joined here.

## Pre-conditions

- `/operator/tenant` passes the operator page gate and can use the shared API
  client.
- Invite mutation accepts `admin:*` or `admin:workspace:tenant-console`. It
  selects tenant from `x-oshun-viewer-tenant-id`, then the auth tenant claim,
  then `house`. `admin:workspace:tenant-console` is not tenant-qualified, and
  this route does not verify the chosen tenant against a tenant-specific scope.
- Cockpit read-back is a separate authorization contract requiring
  `tenant:admin:<tenantId>` or `tenant:admin:*`.
- Deployable BFF startup requires the Postgres-backed tenant invite snapshot,
  validates and hydrates it before traffic, and fails closed if that durable
  sink is unavailable. Invite, member, seat-pool, and erasure-fence state share
  the snapshot.

## Steps

### 1. Open the operator register

The page renders a fixed Pacific Theological register and KPI band, then appends
live invite-store rows after successful mutations/read-back. Export CSV captures
the rendered rows. The trailing per-row `···` cell is presentational; there is
no member action menu.

### 2. Create a pending invitation

Invite opens an accessible dialog for email and one optional free-text role.
`POST /v1/admin/tenant-console/members/invite` validates email syntax, creates a
seven-day pending record, stores only the SHA-256 token hash, returns the raw
token once, and emits `tenant.member_invite`. The response is acknowledged only
after the candidate invite/member/pool snapshot is saved. A failed save returns
`503 tenant_invite_persistence_failed`, leaves the prior state live, and emits
no success audit. New success audit detail identifies the invitation record and
categorical state without copying the target email.

It does not check a tenant email-domain allowlist, approved role catalogue,
reviewer sign-off, or seat capacity. It also does not construct a full URL or
call an email adapter; the operator is told to share the token manually.

### 3. Render the pending row

`TenantMemberRegister` calls the tenant-console cockpit and adds the invitation
as a live `invited` row. If that read fails, it inserts a client-side fallback
row from the mutation response. The fixed “247 members” heading and KPI values
do not change, so the row is stronger evidence than the masthead count.

### 4. Open the signed-in acceptance page

The working hand-off is
`/tenant/invite?inviteId=<id>&token=<token>&tenant=<label>&next=/onboarding`.
The page sanitizes `next`, displays the query tenant as context, and waits for
an explicit Accept click. The email-style root link, authentication redirect,
SSO claim mapping, and MFA challenge are not part of this implementation.

### 5. Accept and allocate a seat

The acceptance route requires any authenticated user, then verifies invite id,
pending state, expiry, token hash in constant time, and seat capacity. It does
not compare the authenticated account's email to the invited email; possession
of the token authorizes binding the current user id. The tenant query parameter
is not checked against the stored invite; the BFF uses the invite's tenant.

On success it creates an active member with the invite roles, allocates one
seat, marks the invite accepted, links that invite internally to the exact
authenticated subject, and commits invite/member/pool together before the
response. The subject link is omitted from every public invite view. It lets
profile export and erasure find this acceptance even if the account email later
differs from the invited address. A failed save publishes no acceptance or seat
allocation. Second use returns `tenant_invite_not_pending`; bad token returns
`tenant_invite_token_mismatch`.

### 6. Continue only by explicit link

The accepted panel shows account, tenant, status, and roles and renders
“Continue to onboarding.” The link is not an automatic navigation and carries no
verified tenant defaults. From this point `/onboarding` uses the ordinary
customer onboarding contract documented in its own journey.

### 7. Observe the missing lifecycle joins

Acceptance does not append an audit event. No code in this path records
`tenant.member_first_login`, `tenant.role_assigned`, or
`tenant.member_first_action`; no first domain action updates the member's
`lastSeenUnixSeconds`; and the operator register has no drill-down or refreshed
KPI proof.

The direct “+ Add member” and SCIM routes are adjacent provisioning options, not
steps in this invitation sequence. Direct add bypasses token acceptance; SCIM
writes a separate projection.

## Post-conditions

- A pending invitation has a durable token hash, tenant, email, role ids, and
  expiry; raw token is visible once.
- Successful acceptance atomically creates an active member, consumes one seat,
  closes the invitation, and survives BFF restart as one validated snapshot.
- Signed profile export finds memberships and accepted invitations by exact
  subject even after an email change. Signed account erasure removes those rows,
  releases their seats, and leaves an exact-user resurrection fence.
- New invite/member audit rows preserve event type, tenant, record identity,
  categorical state, roles, and status without raw target email or user id.
- The customer can choose to open ordinary onboarding through a sanitized link.
- Invite creation is audited; acceptance/onboarding/first action are not joined
  into the tenant audit chain.
- No email delivery, invite-email/account equality, tenant-qualified workspace
  authorization, SSO/MFA, completed preferences, session mint, or first-use
  proof is provided by this journey.

## Failure modes

- **Cross-tenant workspace authority** — the unqualified tenant-console admin
  scope can pair with an arbitrary valid viewer-tenant header on mutation.
- **Email/account mismatch** — any signed-in account holding the valid token can
  accept; invited email is not matched to the auth identity.
- **No delivery** — the raw token can remain in the operator dialog with no
  branded link, provider receipt, bounce, resend, or suppression state.
- **Capacity races at acceptance** — invite creation succeeds without a seat;
  later acceptance can return `409 tenant_seat_capacity_exceeded`.
- **Persistence failure** — any invite/member/seat mutation whose snapshot save
  fails returns controlled `503`; the prior published snapshot remains live.
- **Replica race** — mutation ordering is per BFF process and uses one whole
  snapshot; there is no distributed compare-and-swap across replicas.
- **Retention/audit history** — terminal invites have no general time-based
  purge, historical audit rows containing older target PII are not rewritten,
  and successful acceptance still has no dedicated audit event.
- **Expired/reused/revoked token** — explicit 410/409 states are returned, but
  no operator resend/revoke control is exposed in this page.
- **Fallback row ambiguity** — a cockpit-read failure can still show the
  mutation response as a live-looking row, marked only by refresh-state data.
- **Static metrics** — member/active/KPI figures do not incorporate accepted or
  added members.
- **Audit chain stops** — acceptance has no event, so absence of
  `member_first_login` cannot distinguish “not accepted” from “accepted but not
  audited.”
- **Onboarding link only** — navigating to `/onboarding` does not prove wizard
  completion, tenant defaults, or a first action.

## E2E coverage

- [`apps/oshun/web/e2e/operator-surfaces.spec.ts`](../../apps/oshun/web/e2e/operator-surfaces.spec.ts)
  covers operator entry, tenant fixture register, actions, responsive layout,
  and anonymous gates.
- [`apps/oshun/web/e2e/tenant-onboard.spec.ts`](../../apps/oshun/web/e2e/tenant-onboard.spec.ts)
  protects the rendered tenant-onboarding affordance contract.
- [`apps/oshun/web/e2e/tenant-member-actions.spec.ts`](../../apps/oshun/web/e2e/tenant-member-actions.spec.ts)
  drives browser Invite/Add, real BFF/cockpit rows and audits, plus signed-in
  accept, second-use, wrong-token, validation, and capacity branches.
- [`apps/oshun/web/e2e/tenant-invite-role-bff.spec.ts`](../../apps/oshun/web/e2e/tenant-invite-role-bff.spec.ts)
  proves role ids and the broad admin scope gate at the API layer.
- The final isolated exact-source run of the two affected BFF-backed specs above
  passed 12/12 in Chromium, including public omission of the internal accepted
  subject link.
- **Coverage depth: partial.** Creation and acceptance are deep. Email,
  auth/SSO/MFA entry, wizard completion, first action, acceptance audit, and
  operator reconciliation are missing implementation joins.

## Per-view files touched

- [`operator/operator-tenant.md`](../operator/operator-tenant.md) — fixed
  register plus live invite/member rows.
- [`customer/01-onboarding/tenant-invite.md`](../customer/01-onboarding/tenant-invite.md)
  — protected acceptance page.
- [`customer/01-onboarding/onboarding.md`](../customer/01-onboarding/onboarding.md)
  — explicit next link only.
- [`shell/04-auth-session.md`](../shell/04-auth-session.md) — ordinary auth
  boundary; no invite-specific SSO hand-off.
- [`shell/05-notifications.md`](../shell/05-notifications.md) — planned email
  channel, not called here.

## Cross-references

- [`tenant-bulk-member-invite-scim.md`](./tenant-bulk-member-invite-scim.md) —
  separates CSV, Invite/Add, and SCIM projections.
- [`onboarding-ten-steps.md`](./onboarding-ten-steps.md) — ordinary wizard
  behavior after the link.
- [`tenant-audit-log-investigation.md`](./tenant-audit-log-investigation.md) —
  current audit evidence and missing acceptance row.
- Sources: `TenantMemberRegister.tsx`, `TenantMemberActions.tsx`,
  `TenantInviteAcceptPanel.tsx`, `admin-tenant-invitations.ts`,
  `tenant-invite-store.ts`, and `tenant-console-reads.ts` under `apps/oshun`.

## Open questions

- Should acceptance require verified invited-email equality, an explicit
  delegated invitee claim, or another binding beyond token possession?
- How should `admin:workspace:tenant-console` become tenant-qualified on every
  mutation, matching the cockpit read contract?
- Which service generates and sends the full invite URL, stores delivery
  receipts, and supports resend/revoke without exposing the token again?
- What event distinguishes invitation acceptance from first authenticated login,
  completed onboarding, and first tenant action?
- How should live seat/member/active metrics replace the fixture masthead and
  expose failure instead of a fallback row that looks authoritative?
