From 5ac1293621ee281e4eeb1264c70393828718bc4b Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 27 Jul 2026 21:00:37 -0400 Subject: [PATCH] docs: document the attended-decision path and its ingress requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run that cannot confirm something halts and puts a bounded question in front of a person, reachable from a phone. That path shipped across openadapt-types, openadapt-flow, openadapt-desktop, and openadapt-cloud, and the docs said nothing about it beyond auto-generated changelog lines. Extends the existing halt-learn-loop page rather than adding a new one: the phone's "teach the correction" answer is literally the front door to the learn loop already documented there, so the two halves now read as one page — where a halt goes, and what happens when someone teaches the fix. Nav label and closing rationale updated to match. The properties that distinguish this path, all established from origin/main rather than from a summary: - The phone returns a signed decision, not an execution result. The engine re-reads live state and re-verifies before continuing, and refuses when the application has not actually changed. An operator's answer never marks a run verified. - The operator's own completed step is verified, not replayed. - Full evidence stays on the customer-controlled runner. The hosted envelope carries no free-text field at all, so a hosted dashboard shows less than the phone by design — documented as the feature it is rather than hidden as a limitation. - Pairing is a one-use QR whose confirmation code is minted on the claiming phone and typed back on the runner. The reversal is what makes it phishing-resistant, so the reasoning is written down. The ingress requirement is deliberately not a footnote. The portal is loopback-only by default and a phone cannot reach it until the customer terminates trusted TLS themselves. A reader who installs and then discovers that has been misled, so it appears in three places they hit before trying: a warning in the concept page, the exact variables and fail-closed rules in the configuration reference, and a plan-for-this callout in the on-prem guide. It is framed as what it is — we did not open a listening surface on someone else's network for our convenience, and in a governance sale that is a strength. Also cross-links from the durable-runtime handover and the identity gate, which both end at a halt and previously said nothing about where it goes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM --- docs/concepts/durable-runtime.md | 5 + docs/concepts/halt-learn-loop.md | 203 ++++++++++++++++++++-- docs/concepts/identity-gate.md | 5 + docs/guides/deploy-on-prem.md | 44 +++++ docs/guides/security-and-data-handling.md | 35 ++++ docs/reference/configuration.md | 35 ++++ mkdocs.yml | 2 +- 7 files changed, 318 insertions(+), 11 deletions(-) diff --git a/docs/concepts/durable-runtime.md b/docs/concepts/durable-runtime.md index afa52b8..e509991 100644 --- a/docs/concepts/durable-runtime.md +++ b/docs/concepts/durable-runtime.md @@ -82,6 +82,11 @@ when it cannot be. Same posture as the [identity gate](identity-gate.md) and [effect verification](effect-verification.md): when the right action is not determined, stop, and here, stop *resumably*. +How that handover actually reaches a person — the bounded question, what an +answer does and does not authorize, and why the engine re-verifies rather than +trusting it — is the +[attended decision path](halt-learn-loop.md#where-a-halt-goes-the-attended-decision). + See the [Run a deployment](../guides/run-a-deployment.md) guide for a worked durable run, and the [CLI reference](../reference/cli.md#resume) for `resume` / `approve` exit codes. diff --git a/docs/concepts/halt-learn-loop.md b/docs/concepts/halt-learn-loop.md index 44f6075..8416ade 100644 --- a/docs/concepts/halt-learn-loop.md +++ b/docs/concepts/halt-learn-loop.md @@ -1,16 +1,189 @@ -# The halt-learn loop +# Attended decisions and the halt-learn loop -A [halt](identity-gate.md) is honest, but a halt that teaches the system nothing -means the same unhandled state halts forever. The halt-learn loop closes that -gap: an operator demonstrates the fix once, the correction is folded into the -workflow *through the governed induction path*, a regression gate proves the -revision weakens nothing, and that state never halts again. +A [halt](identity-gate.md) is honest, but a halt nobody hears is just a stopped +run, and a halt that teaches the system nothing means the same unhandled state +halts forever. This page covers both halves of the answer: **where a halt goes** +— the bounded question OpenAdapt puts in front of a person — and **what happens +when that person teaches the fix**, which is the halt-learn loop proper. -It does this **without ever handing control to a free-form agent** and -**without any model call on the runtime path**. A revision is adopted only if it -provably does not regress safety. +Both halves refuse rather than guess. Neither hands control to a free-form +agent, and neither puts a model call on the runtime path. -## The loop +## Where a halt goes: the attended decision + +When a run cannot confirm something, it stops and projects the paused step into +a **bounded question** a member of staff can answer — from a workstation, or +from a phone on your own network through ingress you control. + +The question is closed by construction. `openadapt-flow` projects the pause into +a signed task carrying typed categories, bounded counts, and digests; the client +owns every sentence a person reads. The runner never sends prose and the phone +never renders a runner string, so protected content has no field on the wire to +travel in. What the operator sees is composed locally: which step could not +start, what kind of target it was looking for, what the +[resolution ladder](capability-ladder.md) tried on each rung, and what the +engine will re-prove if they continue. + +The client is a **responsive web app served by the runner itself** — deliberately +not a native iOS or Android application, so there is no app store, no separate +update channel, and no second place for protected evidence to come to rest. + +### The phone returns a decision, not an execution result + +This is the property everything else rests on. Answering does not perform the +step, and it does not mark the run verified. + +When an answer arrives, the engine: + +1. validates the signed task digest, the exact pause capability, and the + engine-derived allowed-action list; +2. takes a single-flight lease, so two people cannot both decide one task; +3. **re-reads the live application** and re-runs its identity, postcondition, + and effect checks against a fresh observation; +4. continues only if that fresh check passes — and **refuses** when the + application is not actually in the state the step needs. + +So "I fixed it" means *I prepared the live state; now go and check it*. It never +means "repeat the paused write". A run reaches `VERIFIED` only when the complete +configured contract proves the intended effect, exactly as on an unattended run. +An operator's answer is an input to a verification, never a substitute for one — +which is why an operator who is mistaken produces a second halt rather than a +silent wrong write. + +!!! note "The operator's own work is verified, not replayed" + When the person completed the step themselves, OpenAdapt advances the resume + point on the strength of **outcome verification** and records the step as a + human-attended actuation. It does not actuate the application a second time. + A step whose postconditions or independent effect check do not pass is + refused, not banked. + +The reply is also honest about delivery. Three states stay distinct and are +never collapsed into one another: not sent, sent, and **may have been sent**. A +decision whose fate is genuinely unknown is reported as uncertain rather than +retried, because a blind retry is how one write becomes two. A fresh idempotency +key does not launder an uncertain delivery into a clean one. + +### Full evidence stays on the runner + +The retained screen, the observed values, the OCR, and the failing target never +leave the customer-controlled runner. Only a typed, PHI-free envelope crosses to +a hosted control plane: opaque identifiers, digests, closed enums, bounded +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: + +- "0 of 2 required identity signals confirmed the record on screen" is a bounded + integer and crosses the boundary safely. +- "could not find the button labelled *Open*" names on-screen content, so it + stays local. + +A hosted surface that says less is a surface that cannot leak more. An operator +who needs the full picture opens the run on the runner, where the evidence +already is — and the hosted surface says so, rather than letting absent detail +read as "OpenAdapt does not know". + +Sending a decision *back* through a hosted control plane is off unless you turn +it on: it requires remote issuance to be explicitly enabled in your deployment +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 + +!!! 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 + loopback URL. **A phone cannot reach it, and a fresh install will not serve + one.** Publishing it to a phone requires *you* to terminate trusted TLS in + 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 + [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) + for where it sits in a deployment. + +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. + +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 +credentials, a hostname where a literal address is required, and a published +mode missing either its public origin or its operator acknowledgement. Any +invalid combination raises and the portal does not start; it never quietly +widens its own exposure to become reachable. There is no self-signed-certificate +bypass and no test-only wide bind. + +### Pairing a phone + +Pairing runs the opposite way round from the obvious design, and the reversal is +the point. + +The runner shows a QR code; the **phone** displays a short confirmation code, +which the operator types back on the runner. The code is minted *on the claiming +device at claim time*, so it is only ever shown to whoever actually claimed the +pairing. + +Do it the intuitive way — derive the code from the pairing and show it on the +runner — and an attacker who photographed the QR from across the room and +claimed it first would be shown the very code the runner's screen was already +displaying. The "matching code" would then confirm the attacker. Minting per +claim means a remote attacker's phone shows a code the operator cannot see, and +the mismatch is visible immediately. + +The rest of the shape follows from the same posture: + +- The QR link carries **only a pairing secret** — no console capability, no + pause capability, no tenant, run, or pause identifier. +- The secret rides in the URL **fragment**, which browsers never transmit, so it + cannot land in a reverse-proxy access log or a referrer header. +- It is **claimable exactly once**, atomically. A second phone scanning the same + code is refused. +- It expires shortly after it is shown, enforced server-side and re-checked at + approval, against both a monotonic and a wall clock so neither a suspended + machine nor a clock change can extend the window. +- A claimed session is **unusable until approved**, attempts are bounded, and + showing a new QR retires every earlier unapproved pairing so a stolen code + cannot sit latent. +- Secrets and session tokens are stored only as digests and compared in constant + time. Paired devices are listed and revocable, and sessions expire. + +The phone never receives the engine's console capability. It holds a session +token bound to that runner and that approved pairing, and nothing else. + +### What the three answers do + +The answers differ in whether your saved workflow changes — which is exactly +what an operator cannot infer from their names, so each one states its +consequence in full: + +| Answer | Effect on this run | Effect on future runs | +|---|---|---| +| Check and continue | Re-verifies live state, then continues | None — the same drift stops the next run | +| Teach the correction | Continues nothing now | Enters the halt-learn loop below | +| Needs more help | Leaves the run paused and untouched | None | + +Operating-system notifications for these are generic by construction: the runner +reads a single count from a PHI-free endpoint and renders a fixed template. No +upstream string is ever forwarded to a notification, on any channel. + +**"Teach the correction" is the front door to the rest of this page.** + +## The learn loop + +Answering a halt keeps one run moving. Teaching the correction is what stops the +same unhandled state halting forever: an operator demonstrates the fix once, the +correction is folded into the workflow *through the governed induction path*, a +regression gate proves the revision weakens nothing, and that state never halts +again. A revision is adopted only if it provably does not regress safety. ```mermaid flowchart TD @@ -123,3 +296,13 @@ not shipped. It is the counterpart to [multi-trace induction](multi-trace-induction.md) (recover the program from several traces) and [policy and certify](policy-and-certify.md) (refuse a bundle whose gaps were not closed): learn only what you can prove safe. + +The attended decision is the same posture pointed at people. It would be easier +to treat a tap as an answer, publish the portal on every interface so a phone +just works, and forward the failing screen to a dashboard where support can see +it. Each of those would move a decision, a network boundary, or a protected +record somewhere it does not belong. Instead the person supplies the one thing a +machine cannot — an observation about the world — and the engine keeps +everything it is actually good at: re-reading live state, checking identity and +effects, and refusing. A halt reaches a phone in seconds, and it still cannot +turn into a wrong write because somebody was in a hurry. diff --git a/docs/concepts/identity-gate.md b/docs/concepts/identity-gate.md index 7d3a4cf..a81be35 100644 --- a/docs/concepts/identity-gate.md +++ b/docs/concepts/identity-gate.md @@ -97,3 +97,8 @@ halts a correct run rather than gamble. That is the cheap direction to be wrong. Clicking by position is what caused wrong-record writes, so OpenAdapt takes the halt. Deployments that cannot tolerate it can escalate each halt to a fallback rather than proceed blindly. + +A halt is not a dead end: it becomes a bounded question in front of a person, +answerable from a phone on your own network. What that person's answer does — +and, importantly, what it does *not* authorize — is the +[attended decision path](halt-learn-loop.md#where-a-halt-goes-the-attended-decision). diff --git a/docs/guides/deploy-on-prem.md b/docs/guides/deploy-on-prem.md index 8f5aba7..1249ca3 100644 --- a/docs/guides/deploy-on-prem.md +++ b/docs/guides/deploy-on-prem.md @@ -227,6 +227,48 @@ identifier; the control there is the trusted local boundary and verified retenti behavior, not redaction. Unset the URL and no model tiers exist (the default install pulls no model). +## Reaching the decision portal from a phone + +When a run halts, OpenAdapt puts a bounded question in front of a person through +the [attended decision portal](../concepts/halt-learn-loop.md#where-a-halt-goes-the-attended-decision). +On-premise, that portal runs on the runner itself and its evidence never leaves +your network. + +!!! warning "Plan the ingress before you pilot the phone path" + The portal is **loopback-only by default**. It binds `127.0.0.1`, and a phone + on your network **cannot reach it** until you put trusted TLS in front of the + runner yourself: an enterprise reverse proxy beside the runner, a VPN, or a + ZTNA hostname. There is no self-signed bypass and no wildcard bind, so this + is not something that can be worked around at install time. + + Budget for it in the same conversation as the rest of your ingress. Locally, + on the runner's own screen, the portal works with no configuration at all. + +This is deliberate, and in a governed deployment it is the easier posture to +defend: OpenAdapt does not open a listening surface on your network on your +behalf. You publish the runner under **your** certificate, **your** access +policy, and **your** logging, so the phone path inherits the controls your +security team already operates and already audits instead of introducing a +parallel one they have to review from scratch. + +The recommended shape is a reverse proxy running beside the runner and +forwarding to loopback — which is why `customer_ingress` still binds loopback +unless you name a specific address. Set the mode, the public origin, and the +acknowledgement together; the portal refuses to start on any partial +combination. The exact variables are in +[Configuration](../reference/configuration.md#the-mobile-decision-portal). + +Two boundary points worth carrying into a security review: + +- **Pairing is one-use and reversed.** The runner shows a QR carrying only a + short-lived pairing secret; the phone shows a confirmation code back, which + the operator types on the runner. The phone never receives the engine's + console capability, and paired devices are listed and revocable. +- **Protected evidence stays local and uncached.** Decision projections and + evidence crops are served `no-store` and are never written to a service-worker + cache. Only raster image types are relayed to the phone; an SVG is refused, + because it is an active document rather than a screenshot. + ## What crosses which boundary | Artifact | Contains identifiers? | Control | @@ -237,6 +279,8 @@ install pulls no model). | `REPORT.md`, console logs | Sanitizer applied when `OPENADAPT_FLOW_SCRUB=on` | Processing errors fail closed; review for detector misses before egress | | `audit/audit.log` | No (PHI/PII-free by construction) | Append-only, hash-chained | | Identity crops to the appliance | Yes | Trusted local boundary; verify egress and retention controls | +| Decision evidence to a paired phone | Yes (rendered pixels) | Stays on your network; `no-store`, never service-worker cached, raster types only, session bound to one approved pairing | +| Decision envelope to a hosted control plane | No (structurally) | Opaque ids, digests, closed enums, bounded counts only; no free-text field exists to carry a value | | Deterministic replay path | n/a | No OpenAdapt-hosted dependency; target and verifier traffic remains | ## Compliance posture, stated honestly diff --git a/docs/guides/security-and-data-handling.md b/docs/guides/security-and-data-handling.md index 6f3c569..622435f 100644 --- a/docs/guides/security-and-data-handling.md +++ b/docs/guides/security-and-data-handling.md @@ -80,6 +80,41 @@ your screens or records**: host is refused outright. ([SANITIZED_ARTIFACTS.md](https://github.com/OpenAdaptAI/openadapt-flow/blob/main/docs/SANITIZED_ARTIFACTS.md)) +### When a halt is answered by a person + +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 portal settings](../reference/configuration.md#the-mobile-decision-portal). +- **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 + service-worker cache; only raster image types are relayed, and an SVG is + refused because it is an active document rather than a screenshot. +- **The hosted envelope cannot represent a record.** What crosses to a control + plane is opaque identifiers, digests, closed enums, bounded counts, and + expiry. There is no free-text field anywhere in it, so values and prose are + *structurally* unable to travel rather than being stripped in transit. A + hosted dashboard therefore shows **less** than the runner-local surface by + design: it can state the shape of a failure, not its content. +- **A person's answer is not a result.** The phone returns a signed decision. + The engine re-reads live state and re-runs its identity, postcondition, and + effect checks before continuing, and refuses when the application is not in + the state the step needs. An accepted tap never produces `VERIFIED`, and + notifications on any channel carry a fixed template and a count — never an + upstream string. +- **Sending decisions back through a hosted control plane is opt-in.** It is off + unless remote issuance is enabled in your deployment configuration and bound + to an exact tenant and runner, and it carries a stronger authentication + requirement. Unconfigured, decisions are local and the hosted view is + read-only triage. + The one deliberate exception is the separate hosted browser recorder for public, non-regulated targets, which sees raw observations inside its own declared hosted boundary. It is a distinct, explicitly initiated lane, not diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index dc7fb75..2ef14a4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -110,6 +110,41 @@ variables supply its endpoint and credentials, keeping them out of the YAML. |---|---| | `OAFLOW_AGENT_TOKEN` | Optional bearer token for the [desktop in-session agent server](../concepts/backends.md#the-in-session-agent-the-session-0-problem). Its `/execute_windows` channel is remote code execution by contract; the server binds to loopback by default, and a token makes every request authenticate. Set it in any PHI/PII deployment that exposes the agent beyond loopback. | +## The mobile decision portal + +These govern where the [attended decision portal](../concepts/halt-learn-loop.md#where-a-halt-goes-the-attended-decision) +listens and what URL it advertises to a paired phone. + +| Variable | Default | Purpose | +|---|---|---| +| `OPENADAPT_PORTAL_INGRESS_MODE` | `loopback` | `loopback` (this computer only) or `customer_ingress` (published through your own HTTPS/VPN ingress). | +| `OPENADAPT_PORTAL_PUBLIC_ORIGIN` | *(unset)* | The exact `https://` origin your reverse proxy, VPN, or ZTNA hostname publishes for this runner. Required for `customer_ingress`. | +| `OPENADAPT_PORTAL_INGRESS_ACKNOWLEDGED` | `false` | Explicit record that your organization operates that ingress and has reviewed protected evidence reaching phones. Required for `customer_ingress`. | +| `OPENADAPT_PORTAL_BIND_HOST` | *(unset)* | Optional **literal IP address** to bind, for when your ingress is not on this host. Left unset, the portal stays on `127.0.0.1` behind a same-host reverse proxy. | +| `OPENADAPT_PORTAL_PORT` | `0` | `0` selects an ephemeral port. | +| `OPENADAPT_PORTAL_CONSOLE_PORT` | `7863` | Loopback port for the supervised attended console the portal relays to. | + +!!! warning "Loopback is the default, and a phone cannot reach it" + 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 + [Deploy on-prem](../guides/deploy-on-prem.md#reaching-the-decision-portal-from-a-phone). + +Every widening step fails closed, and the portal **does not start** on an invalid +combination rather than falling back to something more exposed: + +- A wildcard bind address (`0.0.0.0`, `::`, empty, `*`) is refused in **every** + mode. There is no "bind everything for testing" switch. +- The public origin must be a bare `https://` origin — no plaintext, no path, + no query, no embedded credentials, and no self-signed bypass. +- `OPENADAPT_PORTAL_BIND_HOST` must be a literal IP, not a hostname, and never + an unspecified or multicast address. +- `customer_ingress` requires **both** a public origin and the acknowledgement. +- Setting a public origin or bind host while the mode is still `loopback` is an + error, so a half-finished rollout cannot look configured when it is not. + ## Benchmark (agent arm only) | Variable | Purpose | diff --git a/mkdocs.yml b/mkdocs.yml index 196a3fd..0e0c64e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -143,7 +143,7 @@ nav: - Run a deployment: guides/run-a-deployment.md - Read and audit run reports: guides/run-reports.md - Workflow catalog and halt map: guides/workflow-catalog.md - - The halt-learn loop: concepts/halt-learn-loop.md + - Attended decisions and the halt-learn loop: concepts/halt-learn-loop.md - Governed self-healing: concepts/self-healing.md - Durable runtime: concepts/durable-runtime.md - Deploy on-prem: guides/deploy-on-prem.md