# Accounts (Clavis, plan CL)

An account is how a person signs in: one per person, for life, needing no organisation and no product (IA-4). Built so far:
slice CL1, agreed in `reji/plans/2026-11-07-CL1-accounts-audit.md`; CL2, the sign-in at `id`; CL3, Atrium as the
products' sign-in; CL4, joining; CL5, web addresses; CL6, the account and organisation at `id`; CL7, passkeys, Google and Microsoft; and CL8, the products' side of the API
(`api.md`).

## What an account holds

| Table | What a row says |
|---|---|
| `user` | The account. Its public ID starts `acc_`, and every product stores it. `email` and `password` may be empty: an account may sign in by phone or username alone |
| `account_identifier` | One way it signs in: a phone (E.164), an e-mail or a username, each normalised to lower case. A verified one belongs to one account on the whole platform (`account_identifier_verified_once`); a username is unique whether or not, and its row is kept when retired, so it's never reused. One primary per kind. `shared` false keeps a second e-mail or phone from the account's organisations (CL-17) |
| `membership.claimed_at` | The account accepted this membership's invitation or claimed it |

**`user.email` is the primary verified e-mail, kept by the database.** Writing either it or the identifier writes the other
(`account_identifier_to_user`, `user_email_to_identifier`), so sign-in, mail, the matcher and the Service identity read on
unchanged. New code writes identifiers, through `App\Support\Accounts\Identifiers`.

**The person has no phone of their own any more.** It was only ever one they confirmed (plan SP), so it moved to the account.
The phones a workspace typed are on its memberships, as before.

## The pieces

| Piece | What it does |
|---|---|
| `Identifiers::normalise` | E.164 for a phone (`App\Support\Phone\PhoneNumber`, moved from Nuntius), lower case for an e-mail, `^[a-z][a-z0-9._-]{2,29}$` for a username |
| `Identifiers::add` | Adds one; the first of its kind is primary, and a username counts as verified |
| `Identifiers::verify` | Records that the holder proved it, in its own savepoint, refusing a value another account already holds verified |
| `Identifiers::accountFor` | The account a verified identifier signs in to |
| `User::verifiedIdentifiers` | An account's verified, current identifiers by kind, read once per request |
| `Membership::lets`, `scopeLetting` | Which organisations an account reaches (`people.md`) |

## Signing in (CL2)

One field in two steps, at `id` (manual `s8-sign-in`, Part 0). Every path ends the same way: an enrolled authenticator is
always asked, then the session, with the `Login` event and a new session ID.

| Piece | What it does |
|---|---|
| `SignInName` | Reads what was typed by its shape: an `@` is an e-mail; digits with spaces, dashes, brackets and a leading `+` are a phone (7 digits or more); anything else is a username. It never looks the account up |
| `AccountUserProvider` | The `accounts` driver in `config/auth.php`: Fortify's username is `login`, found by any verified identifier, so the account lock, remember-me, the rehash and the reset broker work as before |
| `SignInController` | The first step (`login.identify`), codes (`login.code`, `login.code.again`, `login.email_code`), the password after a code (`login.code.password`). The session's `sign_in` carries the attempt: what was read, the code's row or the pause, and `proved` |
| `SignInCodes` | Codes: six digits from `random_int`, an HMAC kept, 10 minutes, once, five tries; 30 s, 60 s, then 120 s between; 5 an hour and 10 a day per number or address; 10 an hour per network address; 3 to a range's other numbers in 10 minutes. Constants, not settings: an organisation could only weaken them. Texts through Nuntius (`sign_in_code`, paid by the company); e-mails through the mail engine (`sign_in.code`, to the address typed) |
| `sign_in_code` | One row per code asked for, sent or `held` (`unknown`: no account; nothing sent). Cleared after 30 days by the night's purge (`OldSignInCodes`); erased with a person (`SignInPart`) |
| `sign_in_pause` | Wrong codes per number or address, by an HMAC of it: ten in an hour pause it for 60, 120, 240 minutes, the level kept a day; a new code never resets the count. Held under a row lock |
| `BotCheck` | A second code from one network address within the hour proves a person: a Turnstile token when sent must be good; without one, the puzzle (18 zero bits, 20 behind Turnstile), each solved challenge taken once. `security.bot_check` chooses Turnstile (with `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY`) or the puzzle alone |

**Who may sign in by a code alone.** An account holding the number or e-mail verified, never the Service. An account with a
password that hasn't signed in for `security.quiet_days` (90), or ever, gives its password too (CL-5); a wrong one counts
towards its lock.

**Two faults of Campus's, fixed.** Its pause counted wrong codes with `Cache::increment`, which returns false for a missing key
on the database store, so the pause never tripped; here it's a table. And its range rule counted a number's own resends, so a
fourth code within 10 minutes paused someone waiting for a text; here it counts the range's other numbers. Its code sign-in
also skipped the authenticator; here it is always asked.

**The cost brake** moved to CL4: it holds codes to numbers that don't sign in yet, and sign-in never texts one.

## Signing in to products (CL3)

Atrium is the products' OIDC provider, from the proof's option B (`reji/plans/2026-11-07-CL-P-oidc-proof.md`): Passport,
with a thin layer of our own in `App\Support\Oidc`. A product never sees a code or a password: it sends people to `id` and
gets back tokens it checks itself, against `id`'s published key, so it keeps working from a cached key while `id` is down.

| Piece | What it does |
|---|---|
| `OidcServiceProvider` | Passport's provider, its own discovery off (`composer.json` `dont-discover`). Only the code grant and its refresh; only the routes used, all on `id` (`EnforceHost` refuses `/oauth/*` and `/.well-known/*` elsewhere): `oidc.authorize`, `oidc.token`, `oidc.logout`, `oidc.discovery`, `oidc.jwks`. No `userinfo`: Passport's guard needs its own trait on `User`, which clashes with Sanctum's |
| `oauth_client`, `oauth_auth_code`, `oauth_access_token`, `oauth_refresh_token` | Passport's tables, singular and with zoned times by the house rule, through its model hooks (`ProductClient`, `OauthAuthCode`, `OauthAccessToken`, `OauthRefreshToken`) |
| `ProductClient` | One client per product server (`server_id`), made by `server:sign-in`. Its return addresses are worked out at each request: every live instance of its product, at the client's `callback_path`. An ended instance stops at once. CL5 adds the organisations' verified addresses |
| `OidcAuthorise` | On authorise: PKCE with S256 from every client (League asks it only of public ones). A product sending `acr_values=password` takes someone signed in by code alone to the step-up page, which names the product, then back; with `max_age` (plan LG), also someone whose password is older than that, the page saying "again". A step-up's password moves `auth_time`. An account with no password is told to add one |
| `SignInProof` | What a sign-in proved, in its session: `amr` (`sms`, `email`, `pwd`, `otp`, and `mfa` with two kinds), `acr` (`password` once a password was given, else `code`), `auth_time` and a session ID (`sid`). Set by the `Login` listener, from what a code's way in left in `sign_in_methods`; a password given at step-up adds `pwd` |
| `OidcAuthCodeGrant`, `oidc_auth_context` | When a code is handed out, what the sign-in proved and the `nonce` are held against it; the token endpoint reads the row once and deletes it |
| `IdTokenResponse` | Beside it, `signout_hint` (plan LG): the same signature, audience and account with no details, for the product's sign-out address. The `id_token`, RS256 with Passport's key and its key ID (`OidcKeys::kid`, from the key itself): `sub` is the account's `acc_…`, with `auth_time`, `acr`, `amr`, `sid`, `nonce`; with `profile` the name, with `phone` and `email` the account's own primary verified ones it shares (CL-17). A refresh issues none |
| `AccountAccessToken` | The access token names the account by `acc_…` too, never the row's number |
| `OidcSignOut`, `ProductSessions` | Sign-out from a product ends the session at `id`; back to the product only at one of its own hosts. Any sign-out at `id` sends `account.signed_out` (the account and `sid`) by signed webhook to each server whose product that account has signed into, and to no other. A password reset ends them everywhere (plan LG) |
| `MembershipEnded` | `membership.ended` (the membership, the account, `removed` or `turned_off`) to the servers of the products that organisation holds |
| `server:sign-in` | Makes a server's client, shows its ID and secret once; `--new-secret`, `--callback=` |

**Keys.** `PASSPORT_PRIVATE_KEY` and `PASSPORT_PUBLIC_KEY`, or the files `passport:keys` writes under `storage/`. Tokens last
15 minutes; refresh tokens 30 days; codes 10 minutes; `id_token`s 5 minutes.

## Joining (CL4)

Someone arrives from a product's "Start free" (`/join?product=campus`), or picks one at `/join`, and leaves with an account,
an organisation and that product on Free (manual `s8-sign-in` 0.7). Closed while `sign_up.open` is off, as shipped.

| Piece | What it does |
|---|---|
| `JoinController` | The steps, with the attempt in the session's `join`. The account by a code (`SignInCodes`, purpose `join`): a new number gets one, and one that already has an account signs in to it through `SignInController::afterCode` (quiet password, authenticator), so joining never makes a second account. Then the organisation's form and the start |
| `JoinOrganisation` | In one transaction: the organisation (kind, country, region, district), the account as its owner (the account holder's role, claimed), the product's Free plan and its instance at `{short}.{domain}` |
| `ShortName` | Campus's rule: the initials by default ("International School of Ahafo" is `isa`), 2 to 20 lowercase letters or digits with single dashes, a held list, and free when no live instance of that product answers on it. CL5 adds matching a short name the organisation uses elsewhere |
| `product.domain`, `product_kind` | Each product's own domain (plan IA §2) and the kinds of organisation it serves. A product is offered at joining when it has both and a Free offering. The kind of a new organisation follows from its product |
| `region`, `district` (Input Fields), `tenant.region`, `tenant.district` | Ghana's 16 regions (Campus's ISO codes) and 261 districts, under their regions (`App\Support\InputFields\Places`), taken from the list of districts on 7 November 2026 for the Architect to check. Required at joining where the country has regions; the district must be in its region. Editable on the organisation's profile |
| `SignInWatch` | The cost brake: codes to numbers that don't sign in yet are held (`held = brake`) while `security.code_brake` is on, or when today's texted codes pass `security.code_brake_multiple` times the week's daily average (from at least 50). Never holds a number that already signs in |
| `JoinCodeSent` | `join.code`, the code that proves an e-mail someone joins with, to the address typed |

**Invitations by text.** A membership that holds a phone and no e-mail is invited by text (`invitation.phone`, Nuntius's
`invitation` template, paid as the organisation's, naming the organisation and never the person; the link is secret in the
log). Following the link asks for a code to that number; a right code makes the person's account with the number verified
and no password, claims the membership and signs them in. Changing the membership's phone closes the invitation, as
changing its e-mail does. A number that already signs in gets no invitation: that account is let in already.

**Roles.** A `billing` standard role joins owner, admin and member (CL-16); its permissions come with billing (plan TB).

## Web addresses (CL5)

An organisation's own addresses, one list for every product (CL-18; manual `s6-settings`). Campus's address protocol: a
short name gives `{domain}/{short}` and `{short}.{domain}` at once; the organisation's own address is never assumed, is added
then or later, and routes nothing until its record is found.

| Piece | What it does |
|---|---|
| `organisation_domain` | One address (`host`, unique on the platform) opening one instance of the organisation's (the key holds the organisation on both sides). `verified_at` once found; `checked_at` and `last_answer` for the page |
| `Addresses` | Adds one (a subdomain of a domain it owns: never a bare domain, two-part suffixes such as `edu.gh` counted, never one of ours or a product's), looks it up, and removes it. Another organisation's address is only "taken"; its own names the product it already opens |
| `DnsResolver` | The CNAME lookup (`dns_get_record`); a fake in tests |
| `domains:check` | Every five minutes: new ones for a day, the rest hourly. A verified address whose record is gone stops |
| `ProductClient::hosts()` | A verified address joins its product's return addresses for sign-in |
| `DomainChanged` | `domain.changed` (the instance, the host, `verified` or `stopped`) to each server of the product the address opens, and to no other product's, which must not learn what else the organisation runs |
| `ShortName::suggest` | Offers a short name the organisation already uses on another product, when free, so its addresses match; otherwise its initials |
| `domain.manage`, Settings › Web addresses | Owners and admins add, check and stop addresses (`DomainController`); CL6 moves the page into `id`'s organisation pages |

Products serve verified addresses themselves, so a short outage at Atrium leaves them working; only adding one waits.

## The account and organisation at id (CL6)

| Piece | What it does |
|---|---|
| `Nav::ID_ITEMS` | People at `id` get the mockup's menu: Home; their organisation's People, Details, Products, Web addresses and Settings; then How you sign in, Devices and Security. The starter pack's other workspace pages (roles, logs, imports) stay, reached from Settings and a person's page, but leave the menu |
| `Launchpad`, Home | Home is a launchpad: the organisations, each in its colour with its products as tiles that open them and when the account last used each (its tokens), the one to pick up first, and registering one. An account with no organisation lands here too (`ResolveTenant` opens Home for it), not on a closed page |
| `SignInMethodsController` (`/me/sign-in`) | The account's own phones and e-mails (`aid_…` public IDs), each proved by a code (`SignInCodes`, purpose `verify`, the brake guarding new numbers), the first shared and a second kept private (CL-17); one on another account is said so and sent nothing; removing never takes the last way in (`Identifiers::remove`); a username once, for good; a first password only within 15 minutes of signing in |
| `DeviceController` (`/me/device`) | The account's sessions by browser (`Device::describe`), address and last use, never by their own IDs; one signed out; or everywhere, this one too, every kept browser forgotten, and every product it signed into told (`account.signed_out` with `everywhere`) |
| `Setting\ProductController` (`/setting/product`) | What the organisation runs (plan, addresses, who signed in to it in 30 days) and what fits beside it (products serving its kind); adding one (`product.add`, admins and owners) with the short name it uses elsewhere, its own address now or later, on Free |
| `MakeAccount`, `user.made_by_tenant_id` | An organisation sets up an account for an adult with no phone or e-mail (a username and a password it gives); Clavis holds no children's accounts (CL-13 revised), and may reset it until it's claimed. Called by the products' API (CL8) |
| `ClaimController` (`/me/claim`), `MergeAccounts`, `account_merge` | Claiming (CL-4): a code to a phone or e-mail of one's own makes it theirs and ends the organisation's hold. If that number has an account already, they're asked once, sign in as it, and the school-made account is folded in: every reference re-pointed, its username moved, its person merged (memberships and records with it), the old account gone, the ledger kept, and each organisation's products told (`account.merged`, old and new IDs) |
| `Recipient::forUser` | An account with no e-mail is mailed nothing (CL1's A4): account mails choose no recipient for it, where they once failed |

## Passkeys, Google and Microsoft (CL7)

| Piece | What it does |
|---|---|
| `passkey`, `App\Models\Passkey` | laravel/passkeys' table, singular, with `pky_…` public IDs. Its routes are Atrium's (`passkey.*` in `routes/auth.php`), behind the named limiters; adding one needs a sign-in within 15 minutes or a confirmed password (`RequireFreshSignIn`), so an account with no password can add one too |
| `Passkeys::authorizeLoginUsing` | Never the Service, never a locked account. A passkey proves possession and the person at once (`amr: hwk`), so no second step follows it, and it counts as more than a code (`acr: password`) |
| `@laravel/passkeys` | The browser's side: "Use a passkey" on the sign-in page, "Add one" on How you sign in |
| `ProviderSignIn`, `account_link` | Google and Microsoft (Socialite, as Campus). A linked identity signs its account in; otherwise a Google-verified e-mail the account already holds verified signs it in and links it. Microsoft's e-mail claim is any directory's to set, so it never matches: Microsoft signs in only through a link the account made itself (fixed in CL9). Nothing ever makes an account. Another identity of the same provider on that account is refused, never swapped; one identity links one account. Offered only with both keys set |
| `ProviderController` | Signing in (`amr: fed`, then the authenticator if set up, as any other way in) or linking from How you sign in; unlinking |

## The products' side (CL8)

`POST /v1/account/person`, `GET /v1/account/{account}` and `GET /v1/account/{account}/check` (the `accounts` module; see
`api.md`). `AccountChanges` tells each organisation an account belongs to when its name, or a phone or e-mail it shares,
changes (`account.changed`); `ProductSessions` writes `ended_session` for the recheck, cleared after a day. `OidcAuthorise`
refuses the Service: a product's Service signs in there (CL-14).

**What a product may learn** (`ProductReach`; plan SEC, SEC4 F2). A place a product's server made that matched an account someone
already held by its phone or e-mail is marked `membership.linked_by_product_at`; until the person takes it (`claimed_at`), the
product gets no account in the answer, a 404 for the account's details, a stranger's answer from the recheck, and no
`account.changed`. Reading an account's details is audited (`account.read_by_product`). Any other place lets its products read the
member, as before. A product's sign-out at `id` without that account's ID token asks first (`auth/SignOut`, F7).

## Not built yet

People in `id`'s own design, the billing-only view (plan TB), invitations waiting on Home and a nudge to add a passkey.
The security assessment of `id` (CL9) is `reji/docs/SECURITY_ASSESSMENT_ID.md` (Priority 1 done, plan SEC); the platform's is
`SECURITY_ASSESSMENT_ATRIUM.md`. Invitations waiting on Home would also let a person a product linked take the place (F2). Moving Campus's accounts is Campus's own plan.
