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.
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.
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 |
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.
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.
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./api/admin, SSO, SU admin onlyStricter 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.
/r/<token>, no PDHC accountAn external company cannot be required to hold an account before it has a contract — that is the thing being negotiated. Three independent defences:
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.
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.
| 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.
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
toplan.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”.
mint_pat() returns (guid, raw) and
never stores the raw value.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.| 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 |
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.
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.