# People, sign-in and roles: how S2 is built

What S2 built (plan CORE §3.2, with S4's core brought forward by the Architect's decision of 24 October 2026) and how later
slices write against it. The rule from TuteLoop: **a person is who someone is, a membership is that person in one
workspace, a user is how they sign in.**

## The pieces

| Piece | What it holds | Notes |
|---|---|---|
| `person` · `Person` | the person's own: the name on their account, and a phone **they confirmed**, with when | Platform-wide. May have no login, e-mail or phone. The database refuses an unconfirmed phone here (`person_phone_confirmed_chk`); a confirmed one belongs to one person (a partial unique index). The address they sign in with is their account's (`user.email`), held once (plan SN D3: `person.email` was always the same address). No date of birth (plan SP) |
| `membership` · `Membership` | the person in one workspace: **this workspace's record of them** (`first_name`, `other_names`, `last_name`, `date_of_birth`, `email`, `phone`, the search words), `deactivated_at` (and `is_active`, generated from it by the database: never written, plan SN D7), `joined_at`, roles | A workspace record (`BelongsToTenant`). Every workspace page reads these columns, never the person's. An e-mail is unique among a workspace's members (`membership_email_unique`). Removing is a soft delete; added again, they get the same row back with what was typed now |
| `user` · `User` | e-mail (lower-cased, unique), password, two-factor (Fortify's columns, S8), `failed_login_count` and `locked_until` (the lock, S8), language, `current_tenant_id`, `preference` (plain values only: the sidebar; its lists became rows in plan SN D5: `setup_hidden`, `search_pick`, `known_device`) | Always for a person, one per person. Holds no roles. Its name is the person's |
| `person_identifier` · `PersonIdentifier` | an official number: kind (a code from `identifier_kind`, S5), value, issuer, country, expiry, `source_tenant_id` | Each row is the recording workspace's; unique on (`source_tenant_id`, kind, value) (plan SP; it was platform-wide in 1.0.0). It belongs to that workspace's membership of the person: `(source_tenant_id, person_id)` keys to `membership (tenant_id, person_id)`, so it follows a split or a merge and goes when the membership is removed for good (plan SN D2); a platform row (no workspace) is the person's alone. Matched across the platform (CORE-D7). The value is normalised by `App\Support\People\Identifier` |
| `person_merge` · `PersonMerge` | survivor and merged record by public id, a snapshot, the reason, who | Append-only |
| `role`, `permission`, `model_has_role`, `role_has_permission`, `model_has_permission` | Spatie's tables, singular, teams on `tenant_id` | `Role` is a workspace record; its permission cache is kept per workspace (`CurrentTenant::set`) |

## Who someone is: the matcher

`App\Support\People\PersonMatcher::find(Candidate)` runs before anyone new is made, in this order, and the first answer
wins:

1. an identifier (kind and normalised value), anywhere on the platform (this workspace's own row first, then the
   oldest);
2. the e-mail of an account (the address someone signs in with: the only one they confirmed);
3. a phone an account proved (`Identifiers::accountFor`, plan CL1; it was `person.phone`, which CL1 moved to the account);
4. otherwise this workspace's people with the same first and last name and date of birth, as this workspace recorded
   them: **suggestions only**.

An unconfirmed address never links two people. A `Candidate` is built from validated input and never carries an id, so
no request value can choose who someone is (TuteLoop's `MemberController` took a `person_id` from the request: not
copied). A sure match in another workspace links silently and this workspace sees only what it entered.

## Each workspace's own record (plan SP, 25 October 2026)

CORE-D7 as built. The person is platform-wide, so two workspaces can hold the same person; **what each records about
them is on its own membership**, and nothing one workspace enters or changes reaches another:

- **Reads.** The People pages, the list and its export, the API (`PersonResource`), search (`membership.search_vector`,
  `search_text`; an ID matches only one this workspace recorded, a phone only this workspace's), imports, the
  personal-data copy (`MembershipPart` holds the details; `ProfilePart` only the account and confirmed contacts), mail
  to a member (`Recipient::member`), the audit's subject name, and an actor's name in this workspace's logs all read
  the membership. `Membership` has `name` and `initials`, as `Person` has (`HasPersonDetails`).
- **The person's own.** `person.first_name/last_name` is the name on the account (`User::name`): set when they are
  first recorded, then taken from the membership whose invitation they accept (`Person::own`), so it is a name its holder
  accepted. `person.phone` holds only a phone they confirmed (the matcher's evidence); their address is the account's.
- **Writes.** `AddPerson` puts what was typed on the membership (never on the person) and records the ID typed as this
  workspace's, linked or not. `UpdatePerson` changes the membership only; its "changed meanwhile" check reads the
  membership's `updated_at`.
- **ID numbers.** Unique within a workspace. Saving one a person elsewhere holds is accepted, so nothing tells a workspace
  that a number exists on the platform; the pair is written to the platform's audit as `person.possible_duplicate`
  (both public ids and the kind, never the number) and listed by `php artisan person:duplicates`, for `person:merge`.
- **Imports** call a row that links to someone elsewhere *new*, so the check says nothing of who is known elsewhere; the
  row's `made` still says `linked`, for undo.
- **The guard** `PersonDetailGuardTest` refuses `->person->(first_name|…|email|phone|date_of_birth|name|initials)` and
  `person.<those>` in SQL under `app/` and `routes/`, outside four named files.

## Which workspaces an account reaches (plan SP §5)

**A workspace lets an account in only through what it holds for that person, or a place the account claimed** (widened by
plan CL1, A2): a membership is the account's while it holds one of the account's verified e-mails or phones, or while the
account accepted its invitation (`membership.claimed_at`) (`Membership::lets()`, `scopeLetting`). Before CL1 it was the
account's e-mail alone, which shut out phone-only accounts. A claim is per membership, so it never reaches another
workspace the person belongs to. `PersonWorkspaces`
(ResolveTenant, the switcher, API tokens, notification preferences), `Acting` and `Seeing` all use it. Why: a workspace
that linked someone by their ID number could invite an address of its own choosing, and the account would have walked
into every workspace the person belongs to.

- Linked by the account's own address (or a confirmed one): the workspace typed that address, so it lets them in at
  once. Linked by an ID or a phone with another address: the person's page says "They have an account, but it signs in
  with another address…" and shows nothing of how the account is used; the workspace sets its e-mail to the sign-in
  address to let them in. While it is their sign-in address, the e-mail is locked on the form.
- A place made with no details (a workspace's first owner, `joinWorkspace` in tests, the demo) starts from the person's
  own name and the account's address (`Membership::booted`).
- **A squatted account:** a workspace that links someone by their ID and invites an address it controls makes the
  person's one account (it reaches that workspace alone, but the real person can't then be invited). The operator undoes
  it with `person:split` (below, plan PS).

`App\Actions\People\AddPerson` uses it: an e-mail someone in the workspace already uses is a field error; a sure match
already here is a field error; one removed from here gets their membership back; suggestions throw
`PossibleDuplicate` unless the adder said "someone else" (`decision=new`). New memberships get the workspace's default
role (`role.is_default`). `AddPerson::add()` also says how someone came (`new`, `linked`, `restored`), which an
import's undo needs (S15).

The details' rules are `App\Support\People\PersonRules`, which the form (`PersonRequest`) and an import's rows
(`PersonImport`, S15, `imports.md`) both use: a row the form refuses, the import refuses. A membership an import made
carries `import_id` and the file's own number for the person (`original_ref`, unique in a workspace).

Someone is found by an exact contact through `Membership::whereContact()` (an ID this workspace recorded, as
`Identifier` writes it, or from four digits a phone this workspace holds, a leading 0 left off): the People list's search
and search (S16) both use it. Until S16 the list compared the phone with whatever digits the words held, so a word with
none matched everyone with a phone. A membership is searched through Scout on its own generated words
(`membership.search_vector`, `search_text`, moved from `person` by plan SP; `search.md`).

## Merging and splitting

`App\Actions\People\MergePeople` (and `php artisan person:merge {survivor} {duplicate} --reason=`, the only way in
until a plan gives it a page; never on the Service's record) folds a duplicate into
the person kept, in one transaction: memberships move (or, in a workspace both are in, the kept membership takes the
duplicate's roles, its ID numbers there, and everything that pointed at the duplicate's membership, its empty details filled from the other's),
identifiers and the account move, missing confirmed contacts are filled in, and then **every foreign key to `person` and `membership` is re-pointed from the database's own
catalogue** (`MergePeople::references()`), so a product's table is covered without a change here; an append-only
history among them (a consent) moves through the logs' merge door, its key column and nothing else (plan MC). Two accounts are never
merged. A table whose own uniqueness includes `person_id` must resolve its clash first, or the merge rolls back.

`App\Actions\People\SplitPerson` (and `php artisan person:split {membership…} --reason= [--account=follow|stay]`,
plan PS, 25 October 2026) is the undoing of a link that should never have been made. The memberships named (by public
id, at least one of the person's left behind) move to a **new person**, named as the first of them records them; what
hangs on a membership goes with it. Every other row that points at the person and belongs to one of those workspaces
moves too, **found from the catalogue by its workspace column** (`tenant_id`, or `source_tenant_id` for an ID number):
their ID numbers, the `person_key` rows that read their sealed audit rows, and a product's table without a change here.
The account goes to the side it signs in to (`Membership::lets`), with the contacts it confirmed; when that is both
sides or neither, the command refuses until `--account` says. Its API tokens for the side it leaves are revoked.
Nothing is deleted, so the platform's audit row `person.split` (the new person, the memberships, where the account went,
the reason) is the record, and `person:duplicates` no longer offers the pair. Never on the Service's record.

## Signing in

Fortify behind Atrium's own routes (S8; `sign-in.md` has two-factor, the lock, step-up and the idle timeout): an
e-mail and password (`pages/auth/SignIn.vue`), the named limiter `sign-in` (its figure the setting
`security.sign_in_attempts`), and the sign-in log: `login`, `login_failed` (the attempted e-mail, reason `password` or `unknown`), `logout`,
`password_reset_completed`. A wrong pair says "That e-mail and password don't match an account", never which half. Accounts are made by an invitation (S7): from a person's page, someone active with an e-mail
and no account is sent a link to choose a password at the address this workspace holds (`InvitePerson`,
`AcceptInvitation`; `mail.md`). If the workspace changes that address while the link is open, the database closes it
(plan SN D1): the account would sign in with the old address, which this workspace no longer lets in. On a new installation the first account is the Service's (`service:create`), and a new workspace's Owner comes from New workspace or sign-up (`CreateWorkspace`; `service.md`). A person changes their own name, password and sign-in address on Your account (`sign-in.md`). The operator's `php artisan person:account {email}` (a generated password, shown once)
stays for the first accounts: it finds the one person any workspace recorded with that address.

## Which workspace: ResolveTenant

On every request a signed-in person's workspace comes from their own **active** memberships, read afresh
(`App\Support\Tenancy\PersonWorkspaces`, on the sweep's allow-list), never from a request value:

- the workspace last opened (`current_tenant_id`) while its membership is active;
- that workspace suspended: `workspace/closed?why=paused`, never moved silently (they may switch);
- its membership turned off or removed: the first other active workspace by name, saved; with none, `why=off`, or
  `why=none` with no membership at all.

`ResolveTenant` runs before route binding (`prependToPriorityList`), so a route's records are found inside the
workspace. `CurrentMembership` holds the membership for the gate. The switcher (`POST /workspace/{tenant}/switch`)
accepts only the person's own active memberships in active workspaces. An API call's workspace is its token's membership's (S12, `ResolveTokenTenant`, `api.md`).

## Permissions

- **The catalogue** is `App\Support\Access\Permissions::ALL` (key → level: `workspace` or `platform`). Words:
  `lang/en/permission.php`.
- **The gate** (`AccessServiceProvider`): a `workspace` key is answered by the current membership's roles; anything
  else falls through to its policy. Policies are keyed by permission (`MembershipPolicy`); nobody changes their own
  access.
- **Standard roles** are seed data in `StandardRoles` (the one class that names roles); `roles:sync` makes each in every
  workspace, granting each permission once (`role.seeded_permission`), so it never gives back what an admin took away.
  A new workspace is synced as it is made.
- **The Service** passes every permission outside a lens and has exactly a member's inside one (S3, `service.md`).
- **S4 built** the role editor, the grant ceiling (on roles and on people: nobody acts on someone holding more), the
  held-role guard, handing over ownership and the Roles pages: see `roles.md`.

## The guards

| Test | Proves |
|---|---|
| `People/IdentityTest` | a person with no login; no account without a person, or two for one; one membership per workspace; e-mails without case; sign-in, its log and its named limiter |
| `People/ResolveTenantTest` | the last workspace; **a deactivated membership refused on the next request**; moved to another workspace; paused never moved; no workspace; switching only to one's own; no request value chooses; permissions read in each workspace switched to |
| `People/PersonMatcherTest` | the matcher's order; no link on an unconfirmed address; suggestions only within the workspace; a match elsewhere shows nothing from there |
| `People/PersonMergeTest` | **nothing points at the duplicate** (walks every foreign key, a product's table included); two accounts never merged; the log append-only; the commands |
| `People/PeoplePagesTest` | what each may see and do; no one changes their own access; add, the e-mail clash, "same person?", changed meanwhile, deactivate, reactivate, remove with a reason, removed and added back |
| `Guards/NoRoleNamesTest` | no role named in code (codes read from `StandardRoles`); every permission named is in the catalogue |
