Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions docs/concepts/halt-learn-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ counts, and expiry. There is no free-text field anywhere in that envelope, so
raw values and prose are **structurally unable** to travel rather than being
stripped in transit.

The direct consequence is that a hosted dashboard shows **less** than the phone
on the runner — it can say the *shape* of a failure but not its content. That is
the design working, not a gap:
The direct consequence is that a hosted surface shows **less** than the runner —
it can say the *shape* of a failure but not its content. That is the design
working, not a gap:

- "0 of 2 required identity signals confirmed the record on screen" is a bounded
integer and crosses the boundary safely.
- "the OCR rung was tried and did not resolve a unique target" is a pair of
closed enums and crosses safely too.
- "could not find the button labelled *Open*" names on-screen content, so it
stays local.

Expand All @@ -92,7 +94,45 @@ configuration and bound to an exact tenant and runner, and it carries a stronger
authentication requirement. Unconfigured, tasks are issued for the local
decision surface and the hosted view is read-only triage.

### Reaching it from a phone is your ingress, not ours
### Reaching it from a phone

There are two ways, and they trade fidelity against what your network has to do.
Neither of them asks OpenAdapt to open a hole in it.

#### The hosted lane — nothing to configure

**This is the default answer for a practice without an IT department.** The
runner makes **outbound HTTPS requests only** to the control plane: no inbound
port, no port forward, no certificate, no reverse proxy, no static address. It
works behind NAT on an ordinary broadband line.

1. Connect the desktop app to OpenAdapt Cloud once. It registers this computer
and stores its credential in the operating-system keychain.
2. Turn on remote decisions in your deployment configuration, bound to the exact
tenant and runner the control plane issued.
3. Staff open the hosted queue on a phone and sign in. It is a web page; there
is nothing to install.

That is the whole list. **You do not terminate TLS.** The only TLS involved is
the runner's outbound connection to a public host with an ordinary public
certificate.

What the phone shows on this lane is the *closed halt context*: which category
of check failed, which resolution rungs were tried and what each one returned,
which contracts a "Continue" will re-prove, and bounded counts. Every value is a
closed enum, a bounded integer, or a boolean — **there is no string field and no
image**, so the hosted service is structurally unable to hold a name, an MRN, an
observed value, or a workflow label. It is not scrubbed; it has nowhere to put
them.

The one thing it gives up is the target control's own accessible name. The phone
says *"OpenAdapt could not find the button"* rather than *"the button labelled
`Open`"*, and it tells you a name exists that it is not showing you.

#### The runner-local portal — full fidelity, on your own terms

The portal on the runner serves everything, including the protected screenshot
crops. That is why it is the path with a network requirement.

!!! warning "The portal is loopback-only until you publish it"
Out of the box the decision portal binds `127.0.0.1` and advertises a
Expand All @@ -101,7 +141,7 @@ decision surface and the hosted view is read-only triage.
front of the runner — an enterprise reverse proxy, a VPN, or a ZTNA
hostname — and to record that decision in configuration.

Plan for this before you pilot the phone path. See
Use the hosted lane above if you do not operate one. See
[the portal settings](../reference/configuration.md#the-mobile-decision-portal)
for the exact variables and
[Deploy on-prem](../guides/deploy-on-prem.md#reaching-the-decision-portal-from-a-phone)
Expand All @@ -110,9 +150,9 @@ decision surface and the hosted view is read-only triage.
We did not punch a hole in your network for our convenience, and there is no
"bind everything" switch to make a demo easier. The boundary in front of a
runner that can see protected records is yours to open, deliberately, under your
own certificate and access policy — so the phone path inherits the
authentication, device posture, and logging you already run, instead of asking
you to trust a second one.
own certificate and access policy — so this path inherits the authentication,
device posture, and logging you already run, instead of asking you to trust a
second one.

Every widening step is explicit and **fails closed**. A wildcard bind address is
refused in every mode. So is a plaintext origin, an origin carrying a path or
Expand Down
17 changes: 12 additions & 5 deletions docs/guides/security-and-data-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,19 @@ The same boundary governs the
[attended decision path](../concepts/halt-learn-loop.md#where-a-halt-goes-the-attended-decision),
where a halted run is answered by staff — including from a phone.

- **The decision surface is served by the runner, inside your boundary.** It is
a responsive web app on the runner itself, not a native mobile app and not a
hosted page. It is **loopback-only until you publish it** through your own
trusted TLS ingress; it fails closed on any partial configuration and offers
no self-signed bypass or wildcard bind. See
- **The full-evidence decision surface is served by the runner, inside your
boundary.** It is a responsive web app on the runner itself, not a native
mobile app and not a hosted page. It is **loopback-only until you publish it**
through your own trusted TLS ingress; it fails closed on any partial
configuration and offers no self-signed bypass or wildcard bind. See
[the portal settings](../reference/configuration.md#the-mobile-decision-portal).
- **If you do not operate an ingress, use the hosted lane instead.** The runner
dials **out** to the control plane, so there is no inbound port, no
certificate, and nothing to configure on your network. That lane carries the
signed PHI-free task and the closed halt context only: closed enums, bounded
integers, and booleans, with **no string field and no image**. It is not
scrubbed evidence — it is an envelope that cannot represent a record. See
[Reaching it from a phone](../concepts/halt-learn-loop.md#reaching-it-from-a-phone).
- **Protected evidence does not leave the runner.** The retained screen, the
observed values, the OCR, and the failing target stay local. Projections and
evidence crops are served `no-store` and are never written to a
Expand Down
44 changes: 42 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,50 @@ listens and what URL it advertises to a paired phone.
Unconfigured, the portal binds `127.0.0.1` and advertises a loopback URL.
That is a complete, working configuration *on that computer* — the pairing
screen says a phone cannot reach it rather than minting a link that fails on
your network. Serving a phone is an explicit decision you make by standing up
trusted TLS in front of the runner. See
your network. Publishing **this** surface to a phone is an explicit decision
you make by standing up trusted TLS in front of the runner. See
[Deploy on-prem](../guides/deploy-on-prem.md#reaching-the-decision-portal-from-a-phone).

**You do not have to.** The hosted lane below reaches a phone from anywhere
with nothing configured on your network; it carries less, and it says so.

## Answering a halt on a phone with no ingress

The runner dials **out** to the control plane — no inbound port, no port
forward, no certificate, no reverse proxy, no static address — so a phone
reaches the queue from anywhere. Turn it on in the deployment configuration,
bound to the exact tenant and runner the control plane issued for this machine:

```yaml
human_decisions:
remote:
enabled: true
tenant_id: <from the control plane>
runner_id: <from the control plane>
# context_tier: remote_closed_context # the default
```

| Variable | Purpose |
|---|---|
| `OPENADAPT_RUNNER_TOKEN` | The per-runner credential the control plane issued. The desktop app stores it in the operating-system keychain when you connect this computer, and passes it to the engine; set it by hand only for a headless runner. |

| Setting | Default | Purpose |
|---|---|---|
| `human_decisions.remote.enabled` | `false` | Must be literally `true`. A truthy string does not enable it. |
| `human_decisions.remote.tenant_id` | *(unset)* | Required when enabled. |
| `human_decisions.remote.runner_id` | *(unset)* | Required when enabled. |
| `human_decisions.remote.context_tier` | `remote_closed_context` | `remote_closed_context` (what broke, as closed enums and bounded integers) or `remote_identifiers` (identifiers and counts only). `local_full` is refused by name — protected evidence never leaves the runner. |

The execution profile applies its own ceiling, and the **weaker** of the two
wins, so configuration cannot widen what a profile permits.

!!! note "Every misconfiguration stops the console"
A missing runner credential, a deployment that did not enable remote
issuance, a read-only console, or a plaintext control-plane origin each
refuse to start rather than run a console whose phone lane is silently
absent. A lane that looks on and is not is worse than one that is plainly
off.

Every widening step fails closed, and the portal **does not start** on an invalid
combination rather than falling back to something more exposed:

Expand Down