Skip to content
53 changes: 52 additions & 1 deletion docs/DECISION_DELIVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,48 @@ NAT and on an ordinary practice broadband line.
# context_tier defaults to remote_closed_context
```

2. Staff open `app.openadapt.ai` on their phone and sign in. It is a web page;
2. Run the attended console with the lane on. Desktop does this for the
operator; the equivalent command by hand is:

```
OPENADAPT_RUNNER_TOKEN=oar_... openadapt-flow console \
--attend --allow-actions --remote-decisions --config deployment.yaml
```

3. Staff open `app.openadapt.ai` on their phone and sign in. It is a web page;
there is no app to install.

That is the whole list. Nobody terminates TLS, because the only TLS involved is
the runner's outbound connection to a public host with an ordinary public
certificate.

### What actually runs, once step 2 is on

`console.decision_supervisor.DecisionSupervisor` owns the whole `runs` root
rather than one run, which is what makes the lane automatic instead of a library
somebody has to call:

- Every cycle it publishes **every** currently open attended pause, so a halt
becomes answerable on a phone without anyone doing anything.
- When an answer comes back it re-scans and requires the relayed `task_id` *and*
`capability_digest` to match a pause that is open **right now**. A decision
that matches none is acknowledged `stale` and never executed. This matters the
moment two runs are halted at once, which is the ordinary case: revalidation
would catch a *wrong* answer, but not a *correct* answer applied to the wrong
run.
- A relay whose deadline has passed, or whose deadline cannot be parsed, is
acknowledged `expired`. An unreadable deadline is not a licence to act.
- It runs as a daemon thread inside the attended console, because that process
already owns the deployment-bound action service, and because
`execute_attended_action` takes a single-flight lease over the pause — so an
answer from the phone and one from the local browser cannot both execute.

Every refusal at start-up is a hard exit, never a disabled feature. An operator
who passed `--remote-decisions` and silently got a loopback-only console would
believe a phone can answer a halt while nothing is listening for one. Missing
runner token, remote issuance not enabled, a read-only console, or a plaintext
control-plane origin each stop the console rather than degrade it.

### Runner-local portal — full fidelity, on the practice's own terms

`openadapt-desktop`'s decision portal serves `local_full`, including protected
Expand Down Expand Up @@ -243,6 +278,22 @@ says the frame is triage context rather than the source of any answer. Building
the crypto before a pilot has told us the closed context is insufficient would
be building the expensive half of the answer first.

## What is not done yet

Stated plainly, because a half-wired lane that reads as finished is worse than
an honest gap.

- **Desktop does not pass `--remote-decisions` yet.** `engine/portal/service.py`
spawns `openadapt-flow console --attend --allow-actions`; adding the flag is a
small change, but the installer bundles a pinned frozen Flow, so the pin has
to move to a release containing the flag first. Until then the lane is
available from the CLI and not from the Desktop toggle.
- **The hosted side must be deployed.** The control plane has to accept the two
new projection fields before the lane carries context; without that it still
publishes and answers, at `remote_identifiers`.
- **`max_remote_decision_tier` is not yet discriminating** (see above). It
becomes load-bearing when a tier exists that some profile must refuse.

## What is deliberately not claimed

`DecisionRelay` never says **delivered**. A successful POST proves the control
Expand Down
10 changes: 10 additions & 0 deletions docs/EXCEPTION_INBOX.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ capability, allowed operation, expected transition, expiry, and idempotency
scope. A signed task is presentation integrity, not execution authority, and a
relayed answer is neither.

`console.decision_supervisor.DecisionSupervisor` drives that transport for a
whole `runs` root, so an open pause is published without a caller, and an
answered decision is matched back to the pause it was minted from — by task id
and capability digest, re-read at decision time — before it is executed. A
decision that matches no currently open pause is acknowledged `stale`; one whose
deadline has passed or cannot be parsed is acknowledged `expired`. Neither is
executed. `console --attend --allow-actions --remote-decisions` starts it beside
the loopback server, and every misconfiguration stops the console rather than
disabling the lane silently.

After a returned Continue, OpenAdapt observes a newly settled live frame,
rechecks the human-completed postcondition and configured effect, proves the
next state/target and armed identity, and commits the exact pause transition.
Expand Down
86 changes: 86 additions & 0 deletions openadapt_flow/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4738,6 +4738,28 @@ def _repair_store_flag(rp: argparse.ArgumentParser) -> None:
"fresh attended verification and deterministic continuation"
),
)
p.add_argument(
"--remote-decisions",
action="store_true",
help=(
"Publish open attended pauses to the hosted decision surface over "
"an OUTBOUND-ONLY connection, so a paired phone can answer them "
"from anywhere without any inbound port, certificate, or reverse "
"proxy. Requires --attend --allow-actions, a --config whose "
"human_decisions.remote is enabled with an exact tenant and runner, "
"and OPENADAPT_RUNNER_TOKEN. Only the PHI-free signed task and the "
"closed halt context cross the wire; screenshots never leave"
),
)
p.add_argument(
"--remote-decision-host",
default=None,
metavar="URL",
help=(
"Hosted control-plane origin for --remote-decisions "
"(default: the configured hosted host, https://app.openadapt.ai)"
),
)
_add_backend_flags(p)
_add_deployment_flags(p)
p.set_defaults(func=_cmd_console)
Expand Down Expand Up @@ -4867,6 +4889,69 @@ def _attended_service_from_args(args: argparse.Namespace) -> Iterator[Any]:
raise SystemExit(str(exc)) from exc


def _decision_supervisor_from_args(
args: argparse.Namespace, attended_service: Any
) -> Any:
"""Build the outbound decision lane, or fail loudly. Never returns silently.

Every refusal here is a ``SystemExit`` rather than a disabled feature. An
operator who asked for ``--remote-decisions`` and got a loopback-only
console instead would believe a phone can answer a halt when nothing is
listening for one, which is the exact "looks like it works" failure this
lane must not have.
"""
if not getattr(args, "remote_decisions", False):
return None
if not (args.attend and args.allow_actions):
raise SystemExit(
"--remote-decisions requires --attend --allow-actions: a remote "
"answer is executed through the same governed attended path as a "
"local one, and that path is not available in a read-only console"
)
if attended_service is None: # pragma: no cover - guarded by the check above
raise SystemExit("--remote-decisions requires an attended action service")

from openadapt_flow.console.decision_relay import (
DecisionRelay,
HttpxRelayTransport,
RelayRefused,
resolve_runner_token,
)
from openadapt_flow.console.decision_supervisor import (
DecisionSupervisor,
DecisionSupervisorThread,
)
from openadapt_flow.hosted import HostedError, resolve_host

try:
cfg, _effects_cfg, _actuation_cfg = _deployment_sections(args)
except (FileNotFoundError, ValueError) as exc:
raise SystemExit(str(exc)) from exc
remote = cfg.human_decisions.remote
if not remote.enabled:
raise SystemExit(
"--remote-decisions requires human_decisions.remote.enabled in "
"--config, with the exact tenant_id and runner_id the control "
"plane issued for this machine"
)
try:
token = resolve_runner_token()
origin = resolve_host(getattr(args, "remote_decision_host", None))
relay = DecisionRelay(
HttpxRelayTransport(origin, token), token=token, deployment=cfg
)
except (RelayRefused, HostedError) as exc:
raise SystemExit(str(exc)) from exc
supervisor = DecisionSupervisor(
args.runs, relay=relay, deployment=cfg, executor=attended_service
)
print(
f" remote decisions: outbound-only to {origin} "
f"(tier {remote.context_tier}; no inbound port, no certificate)"
)
return DecisionSupervisorThread(supervisor)


def _cmd_console(args: argparse.Namespace) -> int:
# find_spec first so "extra not installed" is distinguishable from "the
# console package itself is broken" (a wiring bug must surface, not hide
Expand Down Expand Up @@ -4898,6 +4983,7 @@ def _cmd_console(args: argparse.Namespace) -> int:
allow_actions=allow_actions,
attend=args.attend,
attended_service=attended_service,
decision_supervisor=_decision_supervisor_from_args(args, attended_service),
port=args.port,
)
return 0
Expand Down
14 changes: 14 additions & 0 deletions openadapt_flow/console/decision_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
AttendedActionExecutor,
AttendedActionRefused,
AttendedDecision,
AttendedRelayBinding,
)

#: Wire schema of a relayed decision, as minted by the hosted control plane.
Expand Down Expand Up @@ -222,6 +223,18 @@ def relay_signature(self) -> str:
def action(self) -> str:
return str(self.relay["action"])

def durable_binding(self) -> AttendedRelayBinding:
"""Exact PHI-free fields required to recover a lost acknowledgement."""
return AttendedRelayBinding(
decision_id=self.decision_id,
relay_digest=self.relay_digest,
relay_signature=self.relay_signature,
idempotency_key=str(self.relay["idempotency_key"]),
capability_digest=str(self.relay["capability_digest"]),
event_sequence=int(self.relay["event_sequence"]),
action=self.action, # type: ignore[arg-type]
)


class RelayTransport(Protocol):
"""The one network capability this module needs.
Expand Down Expand Up @@ -569,6 +582,7 @@ def execute(
deployment=self._deployment,
principal=principal,
executor=executor,
relay_binding=decision.durable_binding(),
key=key,
)

Expand Down
Loading