# Unified Approval Control 0.4.0

Implemented for Pi 0.85.1 and Codex 0.154.0. Firmware, Gateway, control layer and
wire protocol are 0.4.0. Voice/settings integration is described in [push-to-talk](voice-input-design.md).

## Authority and lifetime

`ApprovalRegistry` is the only decision state machine: PENDING → APPROVED,
REJECTED or CANCELLED. **NO DECISION = KEEP WAITING.** `EXPIRED` remains only as
an accepted compatibility state; the default path never produces it. Internal
`timeout_policy` permits only `wait`, and is not exposed in either UI. Wire
`expires_at` / `expires_in` are null. There is no countdown or decision timer in
Gateway, Pi client, firmware or Admin.

Each approval has a random 128-bit ID, agent, session/project, kind/title/detail,
choices, UTC creation time and internal owner/request identity. Futures and
decisions live only in Gateway memory. Maximum 128 pending approvals and a bounded
terminal tail (384 entries total). A restart cancels authority; no decision replay.
Runtime shutdown, ended session/turn, failed Codex generation, Pi Unix disconnect,
Gateway shutdown/restart, externally resolved request or explicit cancel means
CANCELLED, never an inferred human REJECTED.

All authenticated online devices receive the oldest pending item. Gateway tracks
one currently presented ID per WebSocket incarnation, not merely device ID. The
first legal response wins. Every other device receives `approval_cancelled` with
the terminal state, then the next FIFO item. Device disconnect alone leaves a
request available to Admin or another device indefinitely. Device
reconnect re-presents only a still-pending item. Full pending list is in Admin.

`WAITING_APPROVAL` / `waiting_reason` are overlays over real agent lifecycle state.
Resolving removes the overlay without overwriting newer DONE/FAILED/OFFLINE events.
An approval never changes the device's stored target or page.

## Codex protocol and connection ownership

Verified with `codex-cli 0.154.0` and generated locally using:

```bash
codex app-server generate-json-schema --experimental --out /tmp/device-lab-codex-0.154.0-schema
```

Official context: [Codex App Server](https://learn.chatgpt.com/docs/app-server).
Installed schema is authoritative for this implementation:

- `CommandExecutionRequestApprovalParams`: required `threadId`, `turnId`, `itemId`,
  `startedAtMs`; optional `approvalId`, `command`, `reason`, `availableDecisions`.
- `FileChangeRequestApprovalParams`: required same identity/time fields; optional
  `reason`, `grantRoot`. **No changes field.** Paths come from the `fileChange`
  item in `item/started`; unavailable paths use item ID and reason as fallback.
- Both response types use `decision`. Human approve → `accept`, human reject →
  `decline`. A locally cancelled live request uses native `cancel`; disconnect or
  externally resolved requests send no stale reply. UI never sends acceptForSession
  or policy amendments. `availableDecisions` is a presentation hint: real 0.154.0 requests can omit
  `decline` there while the response schema still permits it. One-shot acceptance
  must be offered; persistent-only requests are cancelled.
- `ServerRequestResolvedNotification`: `threadId` + `requestId`; cancels the local
  pending prompt if it was resolved elsewhere. Turn completion also cancels it.

A background task awaits each approval, so the socket reader stays responsive to
other sessions/RPCs. Internal identity includes the exact typed JSON-RPC request
ID, thread, turn, item, optional callback ID and connection generation. Reply
uses the original ID and a generation-checked socket write that cannot reconnect.
Disconnect/runtime close cancels all owned pending requests and response tasks.
The normal adapter reuses the existing daemon; only the smoke script starts its
own temporary server, without restarting the shared daemon.

## Pi policy and transport

Local installed `dist/core/extensions/types.d.ts` confirms `tool_call` has
`toolName`, `toolCallId`, mutable `input`, and handler result `{block?, reason?}`.
The extension awaits a single Unix RPC per mutation call; approval returns
undefined; explicit human rejection returns `Rejected from Device Lab`. Channel
failure/cancellation blocks with a separate `Device Lab control channel cancelled`
reason and never claims that a human rejected the request.
Runtime shutdown closes outstanding channels. No HTTP decision polling.

The shared endpoint is `$DEVICE_LAB_CONTROL_DIR/approvals.sock`, defaulting to
`~/.config/esp32-device-gateway/runtime/approvals.sock`. Directory must be 0700,
socket is 0600, and Linux SO_PEERCRED must match Gateway's UID. Request cwd must
match a registered project. There is no public listener or new token. Each
request includes a runtime UUID, session ID and exact tool-call ID. Disconnect,
shutdown or Gateway close cancels. Connect/register is bounded to five seconds;
Gateway acknowledges registration with PENDING, which removes that timer. A
healthy accepted socket can wait for hours. Socket failure blocks the tool as a
control-channel failure. Other sessions remain independent.

`should_request_approval(tool_name,args)` trusts explicitly named inspection tools
(read, grep/search, ls/list, find). A small positive grammar allows simple shell
inspection (pwd, selected git status/diff/log, ls/cat/head/tail/wc). Composition,
substitution, scripts, mutation tools and unknown tools require approval. This is
a **human workflow gate**, not a shell parser, OS security boundary or sandbox.
Trusted extensions can still change tool input after this hook; installation
order and custom tool semantics belong to the local trusted Pi environment.
Existing interactive sessions must reload the extension; Gateway SDK hosts load it
automatically. Mutation fail-closed behavior is intentional when Gateway is down.

## Device and Admin

StopWatch uses a temporary modal with agent/title, wrapped detail (tail truncated),
A APPROVE / B REJECT. It sends a response only on release after a press belonging
to the currently displayed approval generation. An old held key, simultaneous
buttons, feedback-period click or stale ID cannot approve the next item. Normal
A/B paging remains outside the modal. Completion waits for Gateway confirmation,
then shows APPROVED/REJECTED for one second. Cancellation/disconnect dismisses it. No local expiry is generated.
Gateway owns FIFO; the firmware stores at most four transient presentations.

Approval alert: strong vibration 200 ms, pause 100 ms, 200 ms; audio 1000 Hz/100 ms,
pause 100 ms, 1000 Hz/100 ms, pause 100 ms, 1400 Hz/160 ms. No periodic repeats while waiting. Voice capture stops before this alert plays.

Admin `GET /api/approvals` and `POST /api/approvals/{id}/decision` reuse Registry.
Rows/buttons are keyed by immutable approval ID and survive polling/focus/in-flight
requests. Existing `/api*` Cloudflare Access protection applies; Gateway remains
loopback-only. Device approvals arrive only on authenticated `/device/ws` over
existing WSS + device token. Tunnel, DNS, tokens and Access identity are unchanged.

## Verification

`./scripts/test-server.sh` covers Registry, transports, adapters, Admin interactions,
authenticated WebSocket FIFO/reconnect and a native C++ test of the exact firmware
modal/button state machine. `./scripts/build.sh m5stack-stopwatch` packages dist.

`server/.venv/bin/python scripts/smoke-approvals.py` makes explicit real model calls
in fresh temporary projects, a new Pi session and a new Codex thread. It observes
two seconds of PENDING with no file mutation, then proves reject versus approve.
Results are saved in the printed proof directory. Firmware build and simulation
do not constitute physical sound/vibration/A-B verification.
