fix(acp): address Thufir pass-4 review findings

- Every ask terminal routes through finish_permission(); applied path
  stops on false (poison) immediately instead of looping back. Cancel
  path uses None idle sentinel instead of dummy instant.
- Deadline equality: process expired entries first at entry.deadline ==
  hard_deadline, then return HardTimeout — fail-closed response is
  always written before exit.
- Wire-truth tests: production-path, cancel, and nine-request tests now
  capture child stdin NDJSON and assert parsed exact lines/ids. Temporal
  tests rebuilt around one continuously running loop per scenario.
- Desktop nonce-present = nonce-only: unknown nonce drops the frame
  without falling back to the id map. Legacy fallback keyed by compound
  (channel:session:turn:id), never bare id. Both indexes cleaned on every
  terminal (acp_write, permission_terminal) and backstop (turn_completed,
  turn_error). New tests: FOREIGN-nonce drop + cleanup assertions on both
  indexes for all four terminal paths.
- NIP-AO: permission_terminal in frame-kind table; synchronous policy
  outcomes (rejected/allowed/allow_failed_closed) in reason table with
  explanatory note distinguishing ask vs. synchronous paths.
- Desktop: permission_terminal handler uses pinned uncertain copy; tests
  for live replay and lifecycle-only archive replay.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-07 12:25:10 -04:00
co-authored by Will Pfleger
parent 6607eaf55e
commit 7d277dab4d
5 changed files with 1234 additions and 362 deletions
+19 -3
View File
@@ -124,12 +124,22 @@ below). It is omitted on all other frame kinds.
| `turn_completed` | Terminal lifecycle — emitted when a turn ends (success, cancel, or timeout) |
| `turn_error` | Terminal lifecycle — emitted when a turn ends with an error or process death |
| `control_result` | Acknowledgement telemetry emitted after processing a control frame |
| `permission_terminal` | Observer-only terminal for uncertain permission outcomes (process poison or cancel-during-write). No ACP wire response was confirmed. Carries an `authorization` envelope with `reason = "uncertain"`. Desktop uses this to retire the card without a JSON-RPC response. |
Permission `acp_read` frames (carrying `session/request_permission` calls) always
include an `authorization` envelope. The corresponding `acp_write` (the harness
response) also includes an `authorization` envelope correlated by the same nonce —
this pairs the challenge and answer in the observer log.
Synchronous policy outcomes (`reject`, `allow`, preflight denial) also produce
`acp_write` frames with `authorization` envelopes. Their `reason` values are:
| Policy path | `reason` |
|-------------|----------|
| `reject` policy, preflight denial, ask-unavailable downgrade | `"rejected"` |
| `allow` policy (auto-approval succeeded) | `"allowed"` |
| `allow` policy (fail-closed, no unique allow_once option) | `"allow_failed_closed"` |
**One-write / one-observe contract.** Each pending permission entry produces at most
one ACP wire write and at most one authorized `acp_write` observer event. The write
and the observer event are always emitted together; if the write fails the observer
@@ -165,10 +175,16 @@ call, the `ObserverEvent` carries an `authorization` field:
| `"applied"` | Owner decision was received and written to the agent pipe. |
| `"timed_out"` | No decision arrived before the 300-second per-request deadline; request failed closed (denial). |
| `"cancelled"` | The turn was cancelled while the request was pending; request failed closed (denial). |
| `"rejected"` | `reject` policy, preflight denial, or ask-unavailable downgrade; request denied synchronously without an actionable card. |
| `"allowed"` | `allow` policy auto-approval succeeded; request granted synchronously. |
| `"allow_failed_closed"` | `allow` policy but no unique `allow_once` option available; request denied synchronously. |
The `uncertain` terminal (cancel arriving while the write is in flight) does NOT
produce an `acp_write` observer event — instead the harness emits a
`permission_terminal` observer event with `authorization.reason = "uncertain"` so
`"rejected"`, `"allowed"`, and `"allow_failed_closed"` are emitted on `acp_write` frames
for synchronous policy paths (see [Synchronous policy outcomes](#synchronous-policy-outcomes)).
They are NOT emitted for `ask`-policy pending-map entries.
The `uncertain` outcome does NOT produce an `acp_write` observer event — instead the
harness emits a `permission_terminal` observer event with `authorization.reason = "uncertain"` so
Desktop clients can retire the card without an ACP wire response. The process is
irrecoverably poisoned and will be respawned by the pool. Desktop clients MUST NOT
expect an `acp_write` for every `acp_read` they receive; the corresponding