# 0077 — Every request result carries the fingerprint; a late drop is collected by polling - Status: accepted (amends ARCHITECTURE §7.5.1 step 5, §7.8 step 4, §11.1 `access.status`/`drop.request`/error data, §11.2 `http`, §11.3 `http_request`/`request_secret_from_human`; implements ADR 0035 for every request path) - Date: 2026-10-23 - Area: protocol | agent-ux ## Context Code review round 4 (`research/14-code-review-log.md`, "Round 4 — mcp") raised two low findings: - **SPEC-CONFORMANCE-C4-5.** §7.5.1 step 5 says "MCP results carry the fingerprint". The 3-word request fingerprint is what the user compares with the phone card, to catch a relay that substitutes requests (ADR 0035). Only `access.request` returned it. The most common request path is §7.5.6 step 4: `http_request` (MCP) or `custos http` (CLI) has no grant, so the daemon creates the request. On that path the `needs_approval` error carried only `request_id`. The same was true of `denied`/`expired`, `access.status`, `drop.request` and every MCP result built from them. `custos http` wrote nothing to the TTY. - **SPEC-CONFORMANCE-C4-6.** §7.8 step 4 says MCP returns `{status: "delivered", handle}`. A drop takes the human a paste, a five-field form and two WebAuthn assertions, which often takes longer than the 60 s default wait (120 s at most). After the wait, `request_secret_from_human` returned `{status: pending, handle: null}`, and no tool could collect the handle later. Polling with `request_access{request_id}` said `granted` without the handle name, which the human may have edited. Calling the tool again created a **second** drop request, because coalescing applies only to pending requests (§7.5.5). So the human was asked for the value twice. ## Decision 1. **The fingerprint travels with the request id everywhere (`custos-rpc`).** - `ErrorData` gains an optional `fingerprint`. `RpcError::with_request(id, fingerprint)` sets both fields, and `RpcError::needs_approval(id, fingerprint)` uses it. The daemon sets both on the `needs_approval`, `denied` and `expired` errors of `http.request`. - `AccessStatusResult.fingerprint` and `DropRequestResult.fingerprint` are **required** strings, so a result without one does not decode. 2. **`access.status` returns the delivered handle of a granted drop.** `AccessStatusResult` gains an optional `handle` (`custos:`). The daemon fills it from the same lookup `drop.request` uses (`drop_handle_name`: the latest policy version whose field 9 is this request's hash), so it carries the name the human chose. An access request has no such policy, so it never has a handle. 3. **MCP.** - Every request-related result carries `fingerprint`. This covers `request_access` (create and poll), the `http_request` `needs_approval` result, `request_secret_from_human`, and the `denied`/`expired` tool errors. Tool errors now also carry `request_id`. - `request_secret_from_human` accepts an optional `request_id`. With `request_id` the tool polls `access.status` for up to `wait_seconds`, creates nothing, and returns `{status: "delivered", handle, fingerprint}` once the grant has arrived. Without `request_id`, `name` and `reason` are required (non-blank). - Pending results say which tool to poll with, and ask the agent to tell the user the fingerprint. The tool description says to poll with only `request_id`, because sending `name` and `reason` again asks the human a second time. 4. **CLI.** When `custos http` gets an error that names a request, it writes `Request fingerprint: ` to the controlling TTY, the same line format `custos request` uses. Every CLI error that names a request prints `(request , fingerprint: …)`. ## Alternatives considered | Option | Why not | | --- | --- | | MCP calls `access.status` after every `needs_approval` to fetch the fingerprint | This adds a round trip after a call that already waited up to 120 s, and a second failure mode on the most common path. The daemon has the hash at hand when it builds the error. | | Coalesce a new drop with a granted (not just pending) identical drop | This changes the §7.5.5 coalescing rule that the PWA and daemon share through `core::fatigue`. A coalesced grant could also have been spent already. Polling by id is explicit and creates nothing. | | A separate `drop.status` RPC | This duplicates `access.status`. The request id space is shared, and only the handle field differs. | | Keep `fingerprint` optional in the status/drop results | An older or broken daemon would then silently drop the anti-substitution check. Required fields fail loudly. All three clients live in this workspace. | ## Evidence 1. ARCHITECTURE §7.5.1 step 5 ("MCP results carry the fingerprint"), §7.8 step 4 (`{status: "delivered", handle}`), §7.5.5 (coalescing requires a pending request); ADR 0035 (request fingerprint against relay substitution). Repository, read 2026-10-23. 2. Code before the fix (read 2026-10-23): `rpc/src/lib.rs` `ErrorData {kind, request_id, until_ms}` and `needs_approval` with only `request_id`; `daemon/src/access.rs` `create_request` coalesces only `LogStatus::Pending`; `mcp/src/lib.rs` `request_secret` sets `handle` only when the grant lands within this call's wait. 3. Regression tests, each run before the fix (failed) and after (passed), 2026-10-23: - daemon `src/tests_review4_mcp.rs::{needs_approval_and_status_carry_the_fingerprint, denied_carries_the_fingerprint, drop_answered_after_the_wait_is_delivered_by_status}`. Before the fix: `left: Null`. - mcp `src/tests.rs::request_results_carry_the_fingerprint`. Before the fix: `outcome_unknown`, because the reply with a fingerprint did not decode. - mcp `src/tests.rs::drop_answered_after_the_wait_is_delivered_by_poll`. Before the fix: `request_id` was an unknown argument. - cli `src/tests/cmds.rs::http_needs_approval_shows_the_fingerprint`. Before the fix: exit 5, not 3. ## Consequences - An agent can quote the fingerprint whenever it has a request id, so the user can match the card on every path. The fingerprint still defends against relay substitution only, not against the agent (ADR 0035). - A drop answered after the wait is collected without asking the human again. - Wire change: `access.status` and `drop.request` results without `fingerprint` no longer decode. The daemon, CLI and MCP server ship together, so no mixed versions exist in the PoC. - On `custos http`, the TTY line appears only when the call returns (after `--wait`), not while it is waiting. To match the card while it is still pending, run `custos http --wait 0`: it returns at once with the fingerprint on the TTY.