# LG · Legatus, the Atrium client library (plan IA step 7)

**Status:** LG1 to LG4 built, proved and reviewed, 9 November 2026 (§4.1). LG-1 to LG-14 decided under the Architect's autonomy grant, 9 November 2026, when the Architect asked for the client library
("Build the client library") and set the rule for the session: Atrium only, every change another app needs recorded for later
(`reji/tasks/other-apps.md`). No mockups: the library draws nothing; the screens it leads to are the products' own (§3).

---

## 0 · The one-paragraph version

Legatus is the one package every product installs to join Atrium: people sign in at `id` and come back signed in (OIDC with PKCE, the
ID token checked locally against a cached key, the membership checked once); the product hears Atrium's signed webhooks and ends a
session the moment someone signs out at `id` or leaves the organisation; a 15-minute recheck catches a missed webhook; every `/v1` call
(people, entitlements, texts and e-mail, vouchers, short links, the server's report) goes through one client that sends the key, the
organisation and an idempotency key, retries only what's safe to retry, and turns Atrium's problem+json into a few exceptions. A fake and
a webhook signer let a product's own tests run without Atrium. It's built and proved here, against Atrium's own routes; installing it in a
product is that product's own plan.

**What it is.** The products' side of Atrium, written once.
**Scope.** In Atrium: the package (`packages/legatus`), its tests against Atrium itself, its documentation, and the one Atrium change its
server report needs. Staying in the products: installing it, the local `user`'s account column, who signs in, mapping each tenant to its
organisation, their screens (all recorded in `reji/tasks/other-apps.md`). Out of LG: a JavaScript or non-Laravel client (trigger: the
first product that isn't Laravel), and publishing the package from its own repository (trigger: the first product deployed with it; the
Architect's, since it's a new repository).
**Failsafe.** Atrium down: people already signed in stay signed in (the recheck fails open, and tries again a minute later); the ID
token's key is cached, so only new sign-ins and calls wait, and the product's screen says so; the last entitlement view is kept in the
product's own database and enforced from there. A webhook missed: the recheck finds it within 15 minutes. A key leaked: rotated at the
console with no gap (two live keys, AF), and the library reads the new one from `.env`.

---

## 1 · What exists

Read 9 November 2026. **Atrium's side is built:** the OIDC provider (CL3: authorise with PKCE S256, `acr_values=password` for step-up,
RS256 ID tokens with `sub`, `sid`, `acr`, `amr`, `auth_time`, `nonce`; discovery and JWKS; RP-initiated sign-out with `id_token_hint`),
signed webhooks (C3: `Webhook-Signature: t=…,v1=…`, 5 minutes' tolerance, a delivery may arrive twice), the server key and
`X-Organisation` (A4), and the `/v1` endpoints of every module (`reji/docs/reference/api.md`). **The products have nothing:** Campus,
Desk, Loop and Books all sign people in themselves; none calls Atrium. All four are Laravel 13; PHP 8.3 (Loop) to 8.5 (Desk); every one
keeps sessions, cache and queue in its database.

**Found while reading, and corrected:** `api.md` said the recheck answers `ended_session`; the code answers `signed_out`. The docs
now say `signed_out`.

## 2 · Decisions

**Decided under the Architect's autonomy grant, 9 November 2026.**

| # | Decision | Chosen | Why, and the alternative |
|---|---|---|---|
| **LG-1** | Where it lives | A Composer package in Atrium's repository, `packages/legatus` (`atrium/legatus`, namespace `Atrium\Legatus`), for Laravel 13 and PHP 8.3 or later. Atrium's suite loads it and runs it against Atrium's own routes. Products install it from a path or VCS repository; its own repository comes with the first deployment (the Architect's) | IA §9 step 7: "Atrium, as a Composer package". Its own repository now: a second place to keep in step while the API still moves |
| **LG-2** | Its shape | A Laravel package: a provider, `config/atrium.php`, two routes (the sign-in return and the webhook), two middleware, three commands, two tables, a fake. One client per module returning Atrium's `data`; a voucher's answer, which products branch on, a value of its own | Products share the stack, so a framework package saves each one the wiring. A framework-free client: every product would wire it again |
| **LG-3** | Signing in | The code flow with PKCE S256, `state` and `nonce` in the session; the return address from the request's own host and `ATRIUM_CALLBACK_PATH`; the ID token checked locally (RS256, `kid`, `iss`, `aud`, `exp`, `iat`, `nonce`) against the JWKS, cached a day and fetched again once for an unknown `kid`. Then one `check` call: an account that doesn't belong to the organisation isn't signed in. The product says who that is locally (`Legatus::users(...)`); the library signs them in and regenerates the session | The provider lets any account through to any instance's host (CL3), so membership has to be asked. `GET /v1/account/{id}`: audited each time, and more than sign-in needs |
| **LG-4** | Step-up | Middleware `atrium.password`: a session signed in by code alone goes back to `id` with `acr_values=password`, then on to where it was going | CL-5. A product asking for a password itself would take a credential (IA §6) |
| **LG-5** | Signing out | The product's sign-out route calls `Legatus::signOut()`: local sign-out, then `id`'s end-session with the ID token as hint and the request's own host as the way back, so a shared school computer is signed out of `id` too | CL-8. Signing out locally only leaves `id` signed in on a shared computer |
| **LG-6** | The recheck | Middleware `atrium.session` on signed-in pages: every 15 minutes (`ATRIUM_RECHECK_MINUTES`), `check` with the session's `sid` and sign-in time. A definite answer ends the session (gone, turned off, signed out); no answer keeps it, and asks again a minute later | CL-8: the failsafe for a missed webhook. Failing closed: every product down whenever Atrium is |
| **LG-7** | Webhooks | One route (`ATRIUM_WEBHOOK_PATH`), the signature checked against the secret, or either of two while one rotates; a delivery's `id` remembered two days, a repeat answered 200 and skipped. The library acts on `account.signed_out`, `membership.ended` and `entitlement.granted`/`changed` itself, then raises `WebhookReceived` for the product's own listeners | C3's contract. Raising a class per event: 18 classes for one field |
| **LG-8** | Ending a session from outside | A `legatus_session` row per signed-in session (`sid`, account, organisation, the session's own ID, user, signed in, checked); ending one destroys the session through the session handler, whatever the driver | `account.signed_out` arrives from Atrium, not the person's browser. Marking the user instead ends every device |
| **LG-9** | The failsafe view | The last entitlement view per organisation in `legatus_entitlement`, refreshed by webhook and by `entitlement()`; `Legatus::held($org)` reads it with no call | TB: "a product keeps the last view it had while Atrium is away". The cache: `cache:clear` on a deploy would lose it |
| **LG-10** | Calls | `ATRIUM_URL`, `ATRIUM_KEY`, `X-Organisation` per call (`Legatus::for($org)`); 3 seconds to connect, 10 to answer (files 120); every POST carries an `Idempotency-Key` (given, or made), so a retry can't act twice; retried twice on no connection or a 502/503/504 only when safe (a GET, a DELETE, a keyed POST). Errors: `Unavailable`, `Invalid` (422, with `errors`), `Throttled` (429, `retryAfter`), `Refused` (any other 4xx, with the problem) | AF-2's contract. Retrying every POST: a text sent twice |
| **LG-11** | The server's report | `atrium:report`, scheduled by the package every five minutes: CPU, memory and disk where the host lets it read them (null where it doesn't), uptime, the default queue's waiting and failed jobs, the release, and up to six figures from `Legatus::figures(...)` | AF: "shared hosting can only report what its account sees". **Atrium changes with it:** CPU, memory and disk become nullable in `POST /v1/report` and `server_report`, shown as "not reported" |
| **LG-12** | The products' tests | `Legatus::fake()` answers every call from Atrium's documented shapes, records them for asserting, serves a test key, and `Legatus::idToken([...])` and `Legatus::webhook($event, $data)` make a signed ID token and a signed delivery | A product's suite must never need Atrium running |
| **LG-13** | Checking the set-up | `atrium:check`: settings present, the key answers `/v1/ping`, discovery and keys reachable, the clocks within a minute | The first deployment's question, answered in one command |
| **LG-14** | Adding a person | `Legatus::for($org)->accounts()->add($person, acting: $admin, key: $ref)`: the product's own reference as the idempotency key, so a double click adds once | IA §7, CL-9 |

## 3 · Screens

None of its own. The sign-in return ends at the product's page (`ATRIUM_HOME`), or, when sign-in can't finish, at the product's
sign-in page with `atrium_error` flashed: `unavailable` (Atrium didn't answer), `not_member` (the account doesn't belong to this
organisation), `unknown` (no local user for that account), or `failed` (the return didn't check out). The words are the product's.

## 4 · Slices

| Slice | What | Done when |
|---|---|---|
| **LG0** ☑ | This plan; the register of other apps' changes (`reji/tasks/other-apps.md`) | Written |
| **LG1** ☑ | The package: config, the client and errors, a client per module, the report (with Atrium's nullable fields), `atrium:check` | Every endpoint called through it against Atrium's own routes in one contract test |
| **LG2** ☑ | Sign-in, step-up, sign-out, the recheck, `legatus_session` | A sign-in end to end against Atrium's provider in-process; ended by a recheck and by a webhook |
| **LG3** ☑ | Webhooks, `legatus_entitlement`, the fake and its helpers | Every library-handled event acted on; a product-style test written only with the fake |
| **LG4** ☑ | A fresh reader's review; docs | Findings fixed with their tests; `reji/docs/reference/legatus.md`, the package README |

### 4.1 · What building added

- **LG-12's fake needed a way back from `id`:** `returnFrom()` reads the `state` and `nonce` from the product's own redirect, so a product's
  test signs someone in without knowing either.
- **The sign-in routes' middleware became a setting** (`atrium.middleware`): a product that finds its tenant in a group of its own names
  it. Found when Atrium's own `web` group, with its workspace rules, ran on the test product's pages.
- **Where they were going travels with the sign-in, not in `url.intended`**, which another flow in the same session (another tab, or `id`
  itself in the tests) could take. Only a page on the same host and port.
- **The end-to-end check, live:** a throwaway product on port 8103 against local Atrium in the browser, which found Atrium's stalled
  sign-in (an Inertia XHR can't follow a redirect to another site; now a full page visit) and that a product needs Laravel's `/up` for
  the console's check.
- **The LG4 review** (`reji/docs/SECURITY_REVIEW_LEGATUS.md`, 7 and 7 as found) added: the recheck ending a session on a 403 and on another
  organisation's host, trying once in three seconds, and joining the `web` group by itself; every success required to be JSON; Atrium's
  "still running" waited for; `signout_hint` from Atrium instead of the ID token in the sign-out address; the entitlement ordered by
  Atrium's `as_of` and "holds nothing" kept; step-up lasting `ATRIUM_PASSWORD_MINUTES` (15) with Atrium honouring `max_age`; a password
  reset ending product sessions; POST-only sign-out; deliveries claimed before acting; session IDs encrypted; the fake's shapes corrected
  and one fake a test. Each with its test in `tests/Feature/Legatus/ReviewTest.php`.

## 5 · Open questions

None for now. The Architect's, when the first product installs it: the package's own repository (LG-1).
