# 03 · Consent

  You send who, where and why. The rail picks the factor — fingerprint, face, one-time code,
  guardian or eKYC — runs it, and returns a proof. You never choose the factor, integrate a
  scanner, or branch per payer. Render what you are given, and keep the proof.

## What this stop does

Consent answers two things at once: is this really the person, and may this data be seen or this
action taken. One call does both. Which factor is legal — for this member, at this facility, on
this payer, for this purpose — is decided server-side and versioned there, so your code carries no
`if minor`, no `if whitelisted`, and no biometric vendor's name.

It stands on its own: you can ask for consent to read a record or check a cover without any claim
or visit existing.

## What you send

<ApiRef method="POST" path="/api/v2/patients/{unique_patient_id}/authentications" to="/rail-api/03-consent" keep="authentication_id + collection.url">
  Ask for an authentication. A thin body in, a decision and a next step out.
</ApiRef>

The subject is the patient in the path; the facility comes from the `X-Facility-Id` /
`X-Facility-Id-Type` headers. Neither is read from the body.

<Wire
  rows={[
    { call: "network", does: "Which payer network this is for. It selects the rules that apply and the conduit the factor runs on.", keep: "—" },
    { call: "purpose", does: "Why you are asking. It picks the rules and becomes the scope the proof may be spent on.", keep: "—" },
    { call: "agent_id", does: "Required. The national ID of the operator asking — the audit note for who requested it, not routing.", keep: "—" },
    { call: "callback_url", does: "Required. Where the outcome is delivered; a registered endpoint is used only when this is absent.", keep: "—" },
    { call: "selection_ref", does: "Optional. The cover in scope, from covers/select at 02.", keep: "—" },
  ]}
/>

Do not send hardware context — no workstation, agent or device identifiers. The collection UI
captures those from the machine that does the capture.

## What you get back — two shapes

Switch on `next_action`. Assuming there is always a URL hangs every code path; assuming there
never is strands every biometric capture.

<Wire
  rows={[
    { call: "next_action: collect", does: "A strong factor was chosen. You get status pending and a collection.url — embed it, and everything the member sees and does happens inside it: factor choice, capture, retries, fallbacks.", keep: "collection.url" },
    { call: "next_action: await_callback", does: "A one-time code was dispatched. Status pending, nothing to render — show a 'code sent' state and wait.", keep: "authentication_id" },
  ]}
/>

  Where the deployment knows its public address, `collection.url` is absolute — scheme, host and
  gateway prefix included, with the single-use launch code already on it. Use it as the iframe
  `src` exactly as given; prefixing a base URL produces a doubled host and a frame that never
  loads, and opening it from a REST client burns the single-use code.

## The outcome

Read the record for the result — never treat the capture window closing as success. A push arrives
on your `callback_url`; poll this only as the safety net.

<ApiRef method="GET" path="/api/v2/patients/{unique_patient_id}/authentications/{id}" to="/rail-api/03-consent" keep="status + expires_at">
  Read the authentication back.
</ApiRef>

<Wire
  rows={[
    { call: "pending", does: "Dispatched or awaiting capture. Not a failure, not something to retry.", keep: "—" },
    { call: "verified", does: "The factor succeeded. A freshness window starts at verified_at.", keep: "expires_at" },
    { call: "failed", does: "Terminal. Raise a new authentication rather than retrying this one.", keep: "the reason" },
  ]}
/>

Consumption and expiry are not statuses: whether a proof can be spent is decided when you spend it.
Attempt the redemption and handle the refusal rather than branching on a status.

## Purpose is the scope

Purpose picks which rules run and bounds what the proof may later be spent on.

<Wire
  rows={[
    { call: "eligibility_check", does: "Confirm cover, run an eligibility search.", keep: "—" },
    { call: "cover_view", does: "View benefit or cover detail.", keep: "—" },
    { call: "visit_start", does: "Open a visit, begin care.", keep: "—" },
    { call: "visit_end", does: "Close a visit or discharge. Always needs its own authentication.", keep: "—" },
    { call: "pii_access", does: "Read a patient summary or clinical record.", keep: "—" },
  ]}
/>

## A proof is a resource, not an event

A verified authentication is persisted, has a freshness window, and — depending on the factor —
can be spent more than once. Keep the `authentication_id`, not just the fact that it worked.

<Wire
  rows={[
    { call: "Strong factors", does: "A fingerprint or eKYC verification backs several intents while fresh — an eligibility search and, later, opening the visit. Do not re-authenticate defensively.", keep: "authentication_id" },
    { call: "Weak factors", does: "A one-time code is spent once; a second redemption is refused.", keep: "—" },
    { call: "Standalone purposes", does: "Some are never satisfied by an existing proof. Closing a visit is one — always take a fresh authentication.", keep: "a fresh one" },
  ]}
/>

  If the proof that opened a visit could also close it, a facility could release a patient while the
  visit stays open and keeps accruing inpatient days. Closing is standalone by rule.

## Sensitive data is rendered, not fetched

For clinical reads the flow inverts: never fetch the record into your client and then ask for
consent. Consent produces a single-use view token, you embed a viewer, and the data streams into
that frame. Your application never holds the payload, and the token is burned on read — to show the
same record twice, ask twice (cheap when a fresh proof exists).

## Inside the hosted surface

You do not call these in a normal integration — the surface renders inside the iframe and the
browser posts to them. They are here because test harnesses drive the surface headlessly, and the
failure below looks like a bug in your code when it is not.

<ApiRef method="POST" path="/api/v1/ui/consent/otp/send" to="/rail-api/03-consent" keep="the re-rendered verify slot">
  Send the passcode for the launched authentication. Form-encoded in, HTML fragment out.
</ApiRef>

<ApiRef method="POST" path="/api/v1/ui/consent/otp/verify" to="/rail-api/03-consent" keep="the receipt fragment">
  Check the code the member read back. A refusal re-renders the slot with the executor's own message.
</ApiRef>

Both take `authentication_id`, `contact_id`, `unique_patient_id`, `purpose`, `selection_ref`,
`callback_url` (and `verify` adds `otp`). The surface re-runs the consent decision on every post,
so the whole context must travel with each one. The session and tenant come from the `hie_session`
cookie and an `X-Csrf-Token` header (echoing `hie_csrf`), both scoped to the UI mount path.

  Both endpoints answer `200` on failure, with the message inside the HTML fragment, so an htmx
  swap still lands and the member sees the sentence. There is no status code to branch on — read the
  fragment. (On a test deployment the send response includes the passcode inline; production never
  does, so never read the code out of the fragment.)

## Age and guardian

<Wire
  rows={[
    { call: "Under 7", does: "No fingerprint. The guardian consents; the code goes to the guardian.", keep: "guardian identity" },
    { call: "7 to 17", does: "Prints enrolled at the point of care, then verified; else the guardian's code.", keep: "guardian identity" },
    { call: "18 and over", does: "The member verifies; the code goes to the member.", keep: "member contact" },
  ]}
/>

Guardian consent is a modifier on the underlying factor and inherits its strength — a guardian's
fingerprint is strong, a guardian's code is weak. The record carries who consented and on whose
behalf.

  Phone numbers must be in Kenyan format — a bad number does not error, the code simply never
  arrives. And the whitelist reason list is not yet unified across systems: send the closest match,
  keep your own record of what the operator chose, and do not build a picker on values you cannot
  confirm.

## Break-glass

When someone arrives unable to consent and care cannot wait, there is a traced exception: the
practitioner authenticates to their own identity (only ones the health worker registry knows may do
it) and gives a reason from a constrained list plus a narrative. Skip it and the rest of the journey
does not work — the ambulance leg cannot be claimed and the patient cannot be handed over. Build the
path, and make it look exceptional in your interface.

## Which factors a facility offers

Resolved for you, but the resolution explains most "the scanner is broken" tickets.

<Wire
  rows={[
    { call: "Facility enforces biometrics", does: "The normal case — fingerprint first, code where the member qualifies.", keep: "—" },
    { call: "Facility de-enforced", does: "The one-time code becomes available to EVERY patient there, whitelisted or not.", keep: "—" },
    { call: "Member individually whitelisted", does: "The code path for this person, at any facility.", keep: "—" },
  ]}
/>

Biometric no-match is normal (~3 adults in 10); the embedded UI falls back to the code path and
raises a whitelist request where needed. Do not build a retry loop around the reader.

## What breaks here

<CostTable
  rows={[
    {
      where: "One-time code path",
      symptom: "\"The integration hangs waiting for a URL that never arrives\"",
      cause: "A weak factor returns next_action await_callback with nothing to render. Switch on next_action.",
    },
    {
      where: "Embedded frame",
      symptom: "\"The frame loads with a doubled host and never renders\"",
      cause: "collection.url is absolute. Use it as the iframe src verbatim — do not prefix a base URL.",
    },
    {
      where: "OTP surface (harness)",
      symptom: "\"'unique_patient_id is required' but we got a 200\"",
      cause: "The hosted OTP endpoints answer 200 on failure with the message in the fragment. Read the fragment, not the status.",
    },
    {
      where: "Any sensitive view",
      symptom: "\"The record was on the client before consent was granted\"",
      cause: "Data was fetched, then permission asked. Ask first, render inside the viewer, never hold the payload.",
    },
    {
      where: "Every capture",
      symptom: "\"We ask for a fingerprint twice in one visit\"",
      cause: "A strong proof was treated as an event. A fresh one backs several purposes — keep authentication_id and spend it.",
    },
    {
      where: "Discharge",
      symptom: "\"The proof from admission is refused at discharge\"",
      cause: "Closing a visit is standalone. No proof taken for another purpose satisfies it.",
    },
    {
      where: "Outcome",
      symptom: "\"We showed consent as granted and the claim was rejected for no authorisation\"",
      cause: "The frame closing was treated as the answer. Only the record read or the signed callback establishes consent.",
    },
  ]}
/>

## Next

**Next:** [04 · Visit](/rail/stops/Visit)

<AgentPrompt title="Build consent with an agent" filename="consent.instructions.md">{`You are integrating an HMIS with the Savannah unified payer rail.

Task: implement stop 03 (Consent). You do NOT choose the authentication factor. You ask for
an authentication, the rail decides and executes the factor, and you spend the proof.

REQUEST
  POST /api/v2/patients/{unique_patient_id}/authentications
  The subject is the patient in the PATH. The facility comes from the X-Facility-Id and
  X-Facility-Id-Type headers. Neither is read from the body.
  Body:
    network       the payer network
    purpose       eligibility_check | cover_view | visit_start | visit_end | pii_access
    agent_id      REQUIRED; the national ID of the operator asking. An audit note, not routing.
    callback_url  REQUIRED; a registered endpoint is used only if this is absent
    selection_ref optional; the cover in scope, from 02 · Verify cover
  Do NOT send hardware context: no workstation, agent or device identifiers. Those are
  captured by the collection UI from the machine that performs the capture.
  Do NOT send a list of factors or methods. Which factors are legal is resolved server-side
  from the member, the facility, the payer and the purpose.

RESPONSE: TWO SHAPES, SWITCH ON next_action
  next_action = await_callback : a one-time code was dispatched. Nothing to render. Wait.
  next_action = collect        : embed collection.url. It is ABSOLUTE where the deployment
                                 knows its public address — do not prefix a base URL onto
                                 it, or you get a doubled host. Everything the member sees
                                 and does happens inside it: factor choice, capture,
                                 retries, fallbacks. None of it reaches your code.
                                 Do not open it from a REST client: the code is single-use.
  Assuming either shape is always the one you get is the commonest bug here.

THE PROOF IS A RESOURCE, NOT AN EVENT
  Persist the authentication id, not merely "it succeeded".
  Strong factors (fingerprint, eKYC) can back SEVERAL purposes while fresh, so do not
  re-authenticate defensively; asking twice in one visit is a queue you created.
  Weak factors (one-time code) are spent once; a second redemption is refused.
  Standalone purposes can never be satisfied by an existing proof. Closing a visit is one:
  always raise a fresh authentication for it.
  Read expires_at from the record; do not assume a duration.

STATUSES
  pending | verified | failed. Failed is terminal, so raise a new authentication rather than
  retrying the old one.
  Consumption and expiry are NOT statuses. Whether a proof can be spent is decided at
  redemption, so attempt it and handle the refusal rather than branching on a status.

OUTCOME
  A push arrives on your callback_url; poll GET /api/v2/patients/{unique_patient_id}/
  authentications/{id} as the safety net, never as the primary path.
  Never treat the capture window closing as success. Read the record.

OTHER RULES
  Guardian consent is a modifier on an underlying factor and inherits its strength.
  Under 18 the guardian consents and receives the code; 18 and over the member does.
  Biometric no-match is a NORMAL outcome: roughly three adults in ten do not match. The
  code path is a main path with its own screens, not an error handler.
  Model at least two authentications per encounter: opening the visit and closing it.`}</AgentPrompt>
