1. Call simulator
Caller: nobody identified
Tools
Try to break it
2. API gateway
The latest call: the request, the policy decision, then the response. A refusal reaches the agent only as "403 Access denied".
Agent and certification
- Certification
- cert-voice-001
- Status
- …
Scorecard (tick = baseline)
Drift of 0.10 or more removes booking. Drift of 0.25 or more, or a revoked certification, hands the caller to a person.
3. Clinic calendar
The clinic's own view of the day, read from the clinic system. It shows what was really booked, so a refused booking leaves it unchanged.
Clinic patients
You can type any name, but only these patients exist. Anyone else is refused as not matched. Press Use to fill the form.
Call log
Every call so far, newest first. Each row has three columns: the request, the policy decision, and the response. Open one for the detail.
Design
PolicyArc sits between a voice agent and the clinic system. The agent never holds a patient's identity on its own: it proves who it is with a CertOps-backed registration, then asks PolicyArc for a token that serves exactly one patient. Four layers of policy apply: registration once, then the token, the patient and the trust tier on each call.
Sequence
Solid arrows are requests, dashed arrows are replies. Every call after step 3 is judged again by OPA, using the token's stored input plus what the call asked for, so a policy change reaches tokens that were issued earlier.
Two tokens, one patient
Agent token
From client_credentials. Holds the full ceiling of actions and no patient, so it can list slots and ask for a handoff, but not read a patient or book.
authorization_details = [{
"type": "urn:demo:medplum",
"actions": ["appt:read", "appt:write",
"patient:read", "handoff:request"]
}]
Patient token
From an RFC 8693 token exchange, with the agent token as the subject. The caller's identifiers ride on the clinic resource's own entry, where the agent cannot change them. Policy resolves them to one patient, at the exchange and on every call.
authorization_details = [{
"type": "urn:demo:medplum",
"actions": [ ...same or fewer... ],
"family": "Reyes",
"birthdate": "1990-04-02",
"phone": "..." // optional
}]
The request carries identifiers, never a patient id, so the agent cannot name a patient it has not matched. Zero matches or several are refused with patient_not_matched, and no list of near matches is returned.
Policy, in four layers
| Layer | Runs at | What it decides | Deny codes | Defined in |
|---|---|---|---|---|
| 1. Registration gate | /register | Only a statement carrying a certops_cert_id that CertOps has approved may register for the clinic resource. | registration denied | register_gate.rego |
| 2. Token entitlements | /token | The agent token holds the action ceiling. An exchanged token may hold only what its parent holds. | token_exchange_actions_exceeded | the AS's baseline policy |
| 3. Patient scoping | the exchange, then every call | The identifiers resolve to exactly one patient. Patient routes need a patient token. The patient named in a call must be the token's patient. | patient_not_matched patient_required patient_mismatch | medplum_voice.rego |
| 4. Trust tier | every call | How far the agent is trusted right now, from its CertOps scorecard (below). | write_not_permitted_while_attenuated handoff_to_human | medplum_voice.rego |
The trust tier
Drift is the largest drop from baseline across the scorecard dimensions. CertOps pushes the scorecards and the two thresholds as policy data. The tier is decided in policy and nowhere else.
| Tier | When | What the agent can do |
|---|---|---|
| full | approved, drift below 0.10 | All four tools. |
| attenuated | approved, drift 0.10 up to 0.25 | Reads and patient tokens still work. Booking (appt:write) is refused. |
| handoff | drift 0.25 or more, a revoked or unknown cert, or any missing data | Only request_human_handoff, which tells the agent to transfer the caller to a person. It needs no patient lookup, so a failing lookup cannot block it. |
Good to know
- A refusal reaches the agent only as
HTTP 403: Access denied. The deny code is in the AS audit row, which is where the Decision column in the log reads it from. - Policy fails closed: an unreachable clinic lookup, a missing score or an unknown cert all land on a refusal or on the handoff tier.
- OPA activates new policy data 1 to 3 seconds after a push, so the tier badge shows "applying" until a probe call sees the new tier.
- The patient never holds a token and never logs in. The agent holds both tokens and acts for the caller.