# onboard.pdhc — technical manual

Guided, two-sided provider onboarding. Two people on a telephone fill in one
shared agreement from two separate windows; the run ends with a FHIR
`Contract` in contract.pdhc and a Provider Access Token in request.pdhc.

Ports **9120 (app) / 9121 (db)**. Containerised: `onboard_pdhc_app` /
`onboard_pdhc_db`, compose project `onboard_pdhc`.

---

## 1. The problem it replaces

A CLI runbook executed by one person on behalf of two. Everything that went
wrong with earlier provider onboardings went wrong in the gap between what the
provider believed they had agreed and what was actually written down: a legal
name PDHC guessed, a delivery URL typed from memory, a concept count nobody
read back.

Hence the single rule the design turns on:

> **Nobody fills in the other side's facts.**

A field is owned by the admin, by the provider, or shared. An owned field can
only be *set* by its owner and must be *accepted* by the other side; a shared
field needs both acceptances.

---

## 2. The room model

One onboarding call is one **room**.

| Table | Holds |
|---|---|
| `onboarding_rooms` | the call: status, invite/room-code hashes, liveness, contract + PAT guids |
| `room_fields` | one agreed value each: key, value, owner, both acceptance timestamps, locked |
| `room_events` | append-only audit |
| `provider_sessions` | a provider's authenticated session inside one room |
| `room_notes` | free text the admin keeps during the call |

### Field ownership

`app/catalogue.py` is the only authority on who may fill a field in. Fourteen
declared fields plus one dynamic family (`scope.concept.*`), each carrying
Swedish and English labels, plain-language help, technical help, what it maps
to downstream, and a validator.

### State machine

```
DRAFT → NEGOTIATING → AGREED → CONTRACT_WRITTEN → SECRET_ISSUED → VERIFIED → LIVE
```

Forward only. `ABANDONED` is reachable from anywhere **except** `LIVE` — a live
provider is a fact about production, not a draft to throw away; revoke the
credential instead.

`reopen()` is deliberately **not** a transition. It returns a LIVE room to
negotiation while keeping the existing `contract_guid`, because a PAT is bound
to that guid: replacing the contract would dangle the token and break grant
exchange.

`agree()` locks every field.

---

## 3. Two windows, one write path

Everything goes through `app/services/rooms.py`.

- `set_field()` **clears both acceptances**. Without that, one side could
  accept a value, the other could change it, and it would still read as
  accepted.
- `accept_field()` refuses self-acceptance on an owned field.
- `snapshot(room, viewer, lang)` is shaped **server-side**, so neither window
  decides what it may see.

### Admin window — `/api/admin`, SSO, **SU admin only**

Stricter than contract.pdhc's "any professional", and deliberately so: the call
ends by minting a PAT in request.pdhc, whose routes grant `admin` only on
`is_su_admin`. A non-SU professional could negotiate the whole contract and
fail at the last step with the provider on the telephone.

### Provider window — `/r/<token>`, **no PDHC account**

An external company cannot be required to hold an account *before* it has a
contract — that is the thing being negotiated. Three independent defences:

1. **A wrong or expired invite gets a flat 404.** Distinguishing them tells a
   prober which guesses are live.
2. **A spoken room code**, hashed, five attempts. The link goes by email; the
   code is read aloud. An intercepted mailbox is not enough.
3. **A liveness gate.** The room opens only while an admin is hosting, so a
   leaked link is not a standing door.

Invite tokens and room codes are stored hashed and compared in constant time.

State is **polled**, not pushed. Adequate for two people on a call, and it
keeps a long-lived connection off the public surface.

### Browser surfaces (#702)

Two HTML surfaces render the API; they are clients for it, not a second
implementation, and every write goes through the same `rooms` service so the
two cannot drift apart on what is allowed.

| Path | Who | Auth |
|---|---|---|
| `/admin`, `/admin/<guid>` | PDHC administrator | sso session (`/auth/login` → sso.pdhc → `/auth/callback`) |
| `/r/<token>` | the provider | invite token + spoken room code; **no PDHC account** |
| `/api/admin/*`, `/api/r/*` | integrators, scripts | bearer / the same token + code |

**`/r/<token>` is the human page.** That URL is what gets emailed to a
provider, so it belongs to the page a person opens; the JSON provider API
moved to `/api/r/` when the window was built.

**The loader answers browsers and API clients differently.** A browser is
redirected to log in; anything under `/api/` gets JSON. Redirecting an API
client would hand it a login page with a 200, which it would read as success.

**The admin has no route to mint the credential.** The provider's reveal is
what mints it. An admin button would mint first and leave the provider's
reveal returning 409 — with PDHC having held a credential it is meant never
to see.

---

## 4. Outbound calls

| Target | Call | Auth |
|---|---|---|
| plan.pdhc | list / fetch PlanDefinitions | none (read) |
| sso.pdhc | organisation list, link or create | acting admin's bearer |
| contract.pdhc | `POST /fhir/Contract` + `X-Skip-Auto-Provision: 1` | `CONTRACT_SERVICE_KEY` |
| request.pdhc | PAT mint / rotate, signing secret, validate, sandbox dispatch | **acting admin's bearer as `X-API-Key`** |

Two of those deserve explanation.

**`X-Skip-Auto-Provision: 1`** — contract.pdhc otherwise auto-provisions a PAT
skeleton on contract creation, and onboard mints the real one itself. Two
tokens for one contract is how a dangling PAT is made.

**request.pdhc takes no service key.** Its PAT routes are
`@requires_auth @requires_role('admin')`; `requires_auth` does not accept
`X-Service-Key` at all, and `requires_role` caps a blob whose `user_type` is
`service` at `read_write`. No machine key can reach those routes under any
header — by design, which is the right posture for minting a credential. The
acting administrator's own bearer is forwarded instead, which also puts the
real person in request.pdhc's audit log.

---

## 5. Scope is derived, never typed

The contract is always based on an existing care plan: a hand-assembled concept
list can disagree with the plan it claims to implement.

Concepts flatten to a **set keyed by guid**. A concept appearing as a goal's
measurement *and* in several scheduled transactions is **one** concept to send.
Counting occurrences instead is what produced a 34-vs-76 disagreement on a real
onboarding. The count is shown and both sides accept it.

Two plan identifiers are stored **separately**:

- `plan.plandefinition_guid` — what plan.pdhc's API answers to
- `plan.plandefinition_fhir_id` — what goes in `Contract.topic[0]`

They are genuinely different values in production. A service handed the wrong
one reports that the contract has no care plan at all.

Each concept becomes `scope.concept.<guid>.obligation`, defaulting to
`obligatory`. `excluded` leaves the contract entirely — it is not "optional".

---

## 6. Secrets

- `mint_pat()` returns `(guid, raw)` and **never stores the raw value**.
- The provider window reveals it **once**; a retry returns 409.
- Audit rows record the **PAT guid only**.
- `rotate_pat()` exists for the real case of a provider losing the secret
  between reveal and paste; without it their only route forward is a new
  contract, which dangles the old PAT.

### Threat model, public window

| Risk | Mitigation |
|---|---|
| Guessed invite token | 256-bit, hashed at rest, flat 404 on miss |
| Intercepted email | the room code is spoken, never in the same channel |
| Leaked link reused later | liveness gate — shut unless an admin is hosting |
| Code brute force | five attempts, then refused |
| Secret in logs | raw value never stored; audit carries the guid |
| Reading another room | every route resolves the room *from the token*; no room id is accepted from the client |

---

## 7. Configuration

```
APP_PORT=9120
DB_PORT=9121
AUTH_MODE=sso                 # 'off' is local dev only
SSO_BASE_URL / SSO_CLIENT_ID / SSO_CLIENT_SECRET
CONTRACT_BASE_URL / CONTRACT_SERVICE_KEY
REQUEST_BASE_URL              # no key — see §4
PLAN_BASE_URL
INVITE_TTL_HOURS
ROOM_CODE_TTL_MINUTES
```

The app binds `127.0.0.1` only; external traffic arrives via the reverse proxy.

---

## 8. Deployment

Release-symlink layout under `/usr/local/www/onboard.pdhc/`, `current` pointing
at a timestamped release, `shared/` for logs.

Migrations run in the container entrypoint (`flask db upgrade`) **before**
gunicorn starts, and fail loudly rather than serving a stale schema. Deploy
with `docker-compose up -d --build` — `COPY . .` bakes source into the image,
so a restart without `--build` runs the old code.

Rollback is `ln -snf releases/<previous> current` plus a rebuild.

---

## 9. Known gaps

- ~~No HTML UI~~ — **built (#702).** See §3.
- **No SSE.** State is polled (§3); the browser pages reload on a timer and
  pause while a dialog is open so typing is not lost.
- **No end-to-end run yet.** Login, room list, create and abandon are
  confirmed by use. The middle of a call — the provider's fields, the
  contract write, the key reveal, verify and go-live — is tested but has not
  been walked with a real supplier. #693 stays open until it has.
- Rotation, revocation and incident response still live in the CLI runbook
  (sections B–E), which onboard does not replace.
