Skip to main content

supercode_harness/
approvals.rs

1//! ORCH-9 (observed tier): one uniform listing of the approval requests
2//! waiting for an answer, across every harness supercode drives.
3//!
4//! **What a source is, at the pinned harness versions.** An approval is only
5//! listable where some door holds it. At the pin there is exactly one uniform
6//! source plus supercode's own queue:
7//!
8//! * **Live protocol requests** — a request exists while a driven runtime's
9//!   turn is blocked on it, and it is delivered to supercode as an ordinary
10//!   runtime event ([`crate::HarnessEvent`]) on the connection that raised
11//!   it: ACP's `session/request_permission` (hermes, openclaw, grok, gemini,
12//!   goose, supercode), opencode's `permission.asked` bus event, Codex's
13//!   server-to-client `*Approval` reverse requests, Claude Code's stream-json
14//!   `can_use_tool` control request, and — on a joined supercode runtime —
15//!   the frontend broker's own `request` envelope. It stops existing the
16//!   moment `harness.v1.runtimes.respond` answers it.
17//! * **supercode's own queued subagent approvals**
18//!   ([`crate::subagents::QueuedApproval`]) — the requests background children
19//!   raised on the parent's queue.
20//!
21//! **There is no file or database source at the pin.** hermes 0.21.0 has no
22//! `hermes approvals` at all, and openclaw 2026.7.1-2 has no
23//! `approvals pending | resolve | grants` (both are upstream-main-only; see
24//! the version note in `docs/composable-harness/inventory/orchestration.md`
25//! and the committed help fixtures in `crates/harness/src/parity/fixtures/`).
26//! [`ApprovalKind::Stored`] and [`ApprovalKind::Proposal`] are therefore
27//! defined here and never produced: they are the shapes a later pin's stored
28//! operator approvals and allowlist proposals will land in, and nothing in
29//! this module invents them today.
30//!
31//! pi is a further honest absence: its adapter can `respond`, but pi has no
32//! per-tool-call approval system at all at its pin
33//! (`docs/composable-harness/inventory/pi.md` §4), so no pi request shape is
34//! recognized — none is ever emitted.
35//!
36//! **Answering (ORCH-20, controlled tier).** This module never talks to a
37//! harness itself. It TRANSLATES one uniform decision — `allow_once`,
38//! `allow_always`, `deny` — into the option token and reply envelope the door
39//! that raised the request already accepts, and
40//! `harness.v1.approvals.resolve` hands that envelope to
41//! `harness.v1.runtimes.respond`, the harness's own door, unchanged. A
42//! decision the request does not offer is refused by name with the offered
43//! ones listed; nothing is ever guessed at, and no adapter learns a new
44//! vocabulary because of this module.
45
46use std::collections::BTreeMap;
47
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50
51use crate::subagents::{QueuedApproval, QueuedApprovalOutcome};
52use crate::{HarnessEvent, HarnessId};
53
54/// Where a row came from.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ApprovalKind {
58    /// A request a running turn is blocked on right now.
59    Live,
60    /// An operator approval a harness keeps in its own store. No harness at
61    /// the pinned versions has one; see the module docs.
62    Stored,
63    /// A mined allowlist proposal rather than an outstanding request. No
64    /// harness at the pinned versions has one; see the module docs.
65    Proposal,
66}
67
68impl ApprovalKind {
69    /// Stable wire spelling.
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::Live => "live",
73            Self::Stored => "stored",
74            Self::Proposal => "proposal",
75        }
76    }
77}
78
79/// Lifecycle state of one approval row.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ApprovalStatus {
83    /// Waiting for an answer.
84    Pending,
85    /// Answered yes (once or for the session).
86    Allowed,
87    /// Answered no.
88    Denied,
89    /// Timed out before anyone answered.
90    Expired,
91    /// Withdrawn by the side that raised it.
92    Cancelled,
93}
94
95impl ApprovalStatus {
96    /// Stable wire spelling.
97    pub const fn as_str(self) -> &'static str {
98        match self {
99            Self::Pending => "pending",
100            Self::Allowed => "allowed",
101            Self::Denied => "denied",
102            Self::Expired => "expired",
103            Self::Cancelled => "cancelled",
104        }
105    }
106}
107
108/// One answer the door that raised this request accepts.
109///
110/// `id` is the token the harness's own responder expects — an ACP
111/// `optionId`, an opencode reply word, a supercode frontend decision. It is
112/// never invented: a row carries options only where the request itself
113/// enumerates them or the pinned source documents the responder's vocabulary.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ApprovalOption {
116    /// Token passed back to the harness.
117    pub id: String,
118    /// Human label when the request carries one.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub label: Option<String>,
121    /// The protocol's own classification of the answer, when it states one.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub kind: Option<String>,
124}
125
126impl ApprovalOption {
127    fn bare(id: &str) -> Self {
128        Self {
129            id: id.to_string(),
130            label: None,
131            kind: None,
132        }
133    }
134}
135
136/// One approval request, in the vocabulary shared by every harness.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ApprovalRow {
139    /// Stable, addressable identity. Live rows are
140    /// `<connection>/<native request id>`; supercode's queued subagent rows
141    /// are `supercode/subagent/<child>/<queued_at_ms>/<index>`.
142    pub id: String,
143    /// Harness whose door raised the request.
144    pub harness: HarnessId,
145    /// Which source this row came from.
146    pub kind: ApprovalKind,
147    /// Lifecycle state.
148    pub status: ApprovalStatus,
149    /// The tool, command, or edit being asked about, on one line.
150    pub subject: String,
151    /// Harness-native session the request belongs to, when it names one.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub session_id: Option<String>,
154    /// Live runtime the request arrived on, when there is one.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub runtime_id: Option<String>,
157    /// Unix-ms wall-clock time the request was first seen.
158    pub requested_at_ms: i64,
159    /// How long it has been waiting, as of this listing.
160    pub age_ms: i64,
161    /// Answers the door accepts. Empty where the protocol does not enumerate
162    /// them, and always empty on a row that is no longer `pending`.
163    #[serde(default)]
164    pub options: Vec<ApprovalOption>,
165}
166
167/// `harness.v1.approvals.list` request.
168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(default)]
170pub struct ApprovalsQuery {
171    /// Only this harness. Omit for every harness.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub harness: Option<String>,
174    /// Only this session (a harness-native session id, or a child agent id
175    /// for supercode's queued subagent rows).
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub session: Option<String>,
178}
179
180impl ApprovalsQuery {
181    /// Whether one row survives this query's filters.
182    pub fn matches(&self, row: &ApprovalRow) -> bool {
183        if let Some(harness) = self.harness.as_deref() {
184            if row.harness.as_str() != harness {
185                return false;
186            }
187        }
188        if let Some(session) = self.session.as_deref() {
189            let hit = row.session_id.as_deref() == Some(session)
190                || row.runtime_id.as_deref() == Some(session);
191            if !hit {
192                return false;
193            }
194        }
195        true
196    }
197}
198
199/// The uniform decision `harness.v1.approvals.resolve` takes, in the
200/// vocabulary shared by every harness rather than any one door's spelling.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum ApprovalDecision {
204    /// Allow this one call.
205    AllowOnce,
206    /// Allow this call and every matching one for the rest of the session.
207    AllowAlways,
208    /// Refuse.
209    Deny,
210}
211
212impl ApprovalDecision {
213    /// Every decision, in escalating order.
214    pub const ALL: [Self; 3] = [Self::AllowOnce, Self::AllowAlways, Self::Deny];
215
216    /// Stable wire spelling.
217    pub const fn as_str(self) -> &'static str {
218        match self {
219            Self::AllowOnce => "allow_once",
220            Self::AllowAlways => "allow_always",
221            Self::Deny => "deny",
222        }
223    }
224
225    /// Parse a wire or CLI spelling. `allow_once` and `allow-once` are the
226    /// same decision: the CLI hyphenates its positional, the RPC does not.
227    pub fn parse(text: &str) -> Option<Self> {
228        let normalized = text.trim().to_ascii_lowercase().replace('-', "_");
229        Self::ALL
230            .into_iter()
231            .find(|decision| decision.as_str() == normalized)
232    }
233}
234
235/// `harness.v1.approvals.resolve` request.
236///
237/// Exactly one of `decision` and `option_id` is given: the uniform decision
238/// this module translates, or the door's own option token when a caller has
239/// already read it off the row and wants it passed through untranslated.
240#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(default)]
242pub struct ApprovalsResolveParams {
243    /// The row id `harness.v1.approvals.list` reported.
244    pub id: String,
245    /// Uniform decision to translate onto this request's own options.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub decision: Option<ApprovalDecision>,
248    /// One of the row's own `options[].id` values, passed through as-is.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub option_id: Option<String>,
251}
252
253/// What a caller asked for, once the params have been validated.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum ApprovalChoice {
256    /// Translate this uniform decision onto the request's own options.
257    Decision(ApprovalDecision),
258    /// Send this exact option token, after checking the request offers it.
259    Option(String),
260}
261
262impl ApprovalChoice {
263    /// What the caller asked for, as it will appear in a refusal.
264    pub fn asked(&self) -> &str {
265        match self {
266            Self::Decision(decision) => decision.as_str(),
267            Self::Option(option) => option.as_str(),
268        }
269    }
270}
271
272/// Everything `harness.v1.runtimes.respond` needs to answer one request.
273///
274/// This is a plan, not an effect: building it neither touches a runtime nor
275/// forgets the row. The service hands it straight to
276/// `harness.v1.runtimes.respond`, so the answer travels the adapter path that
277/// already existed and no adapter is changed by this verb.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct ApprovalResolution {
280    /// Runtime connection holding the request.
281    pub connection: String,
282    /// Native JSON request id the door expects back.
283    pub request_id: Value,
284    /// Option token actually sent.
285    pub option_id: String,
286    /// The door's own reply envelope carrying that token.
287    pub response: Value,
288}
289
290/// Why one resolve could not be planned.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum ApprovalResolveError {
293    /// No live request with this row id is outstanding on this service.
294    UnknownId(String),
295    /// The row is supercode's own queued subagent record, which is an audit
296    /// entry rather than an answerable door.
297    QueuedSubagentRow(String),
298    /// The request enumerates answers, but none of them is this decision.
299    NotOffered {
300        /// What was asked for.
301        asked: String,
302        /// The option ids the request itself offers.
303        offered: Vec<String>,
304    },
305    /// The request enumerates no answers at all, so there is nothing uniform
306    /// to select. Codex's reverse approval request is the one such door at
307    /// the pin (see [`classify_live_request`]).
308    NoOptions {
309        /// Door that raised it.
310        door: &'static str,
311    },
312}
313
314impl std::fmt::Display for ApprovalResolveError {
315    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        match self {
317            Self::UnknownId(id) => write!(
318                formatter,
319                "no approval request `{id}` is waiting on this service — a live request \
320                 exists only inside the process driving the runtime whose turn it blocks, \
321                 and only until it is answered. Answer it there: the SDK client or TUI that \
322                 started the runtime, or `harness.v1.approvals.resolve` over that same \
323                 `supercode harness serve` stdio session (SUP-62: no cross-process relay)"
324            ),
325            Self::QueuedSubagentRow(id) => write!(
326                formatter,
327                "`{id}` is a queued subagent record — supercode's own audit trail of a \
328                 request the parent's own handler answers (the terminal's modal, or the \
329                 frontend request broker). Answer it on the door that raised it: the \
330                 `request` envelope row of the joined supercode runtime"
331            ),
332            Self::NotOffered { asked, offered } => write!(
333                formatter,
334                "this request does not offer `{asked}` — it offers: {}",
335                if offered.is_empty() {
336                    "(nothing)".to_string()
337                } else {
338                    offered.join(", ")
339                }
340            ),
341            Self::NoOptions { door } => write!(
342                formatter,
343                "this `{door}` request enumerates no answers, so there is no option to \
344                 select — answer it with `harness.v1.runtimes.respond` and that door's own \
345                 reply body"
346            ),
347        }
348    }
349}
350
351impl std::error::Error for ApprovalResolveError {}
352
353/// Which protocol door raised a live request.
354///
355/// A door decides two things this module needs and nothing else: the option
356/// vocabulary a uniform decision maps onto, and the envelope
357/// `harness.v1.runtimes.respond` carries the answer in. Each is the door's
358/// own — see [`ApprovalResolution`].
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum ApprovalDoor {
361    /// ACP `session/request_permission` (hermes, openclaw, grok, gemini,
362    /// goose, supercode's own ACP server).
363    Acp,
364    /// opencode's `permission.asked` bus event.
365    Opencode,
366    /// Codex's `*Approval` server-to-client reverse request.
367    Codex,
368    /// Claude Code's stream-json `can_use_tool` control request, raised to the
369    /// permission handler supercode registers with
370    /// `--permission-prompt-tool stdio`.
371    ClaudeCode,
372    /// supercode's own frontend request broker, on a joined runtime.
373    SupercodeFrontend,
374}
375
376impl ApprovalDoor {
377    /// Stable spelling used in refusal messages.
378    pub const fn as_str(self) -> &'static str {
379        match self {
380            Self::Acp => "acp",
381            Self::Opencode => "opencode",
382            Self::Codex => "codex",
383            Self::ClaudeCode => "claude-code",
384            Self::SupercodeFrontend => "supercode-frontend",
385        }
386    }
387
388    /// How this door spells one uniform decision, best match first.
389    ///
390    /// Each list is the door's OWN vocabulary, never a coinage:
391    ///
392    /// * ACP names the classification on the request — `PermissionOptionKind`
393    ///   is `allow_once | allow_always | reject_once | reject_always` — so a
394    ///   decision is matched against each option's `kind` and, for an agent
395    ///   that sends none, against its `optionId`.
396    /// * opencode's reply set is `once | always | reject`
397    ///   (`docs/composable-harness/inventory/opencode.md` §"Ask/approve flow").
398    /// * supercode's frontend broker takes [`FRONTEND_DECISIONS`].
399    /// * Claude Code's permission handler answers with a `behavior`, and the
400    ///   protocol defines exactly two: `allow` and `deny`
401    ///   ([`CLAUDE_CODE_BEHAVIORS`]). `allow_always` is NOT among them —
402    ///   persisting a rule is a separate `updatedPermissions` field carrying
403    ///   the request's own `permission_suggestions`, which the uniform
404    ///   `(door, options, choice)` translation here does not carry — so the
405    ///   decision is refused by name with the two that are offered.
406    /// * Codex's reverse request enumerates nothing, and this module invents
407    ///   no vocabulary for it (ORCH-9's own stance); its rows refuse with
408    ///   [`ApprovalResolveError::NoOptions`].
409    const fn spellings(self, decision: ApprovalDecision) -> &'static [&'static str] {
410        match (self, decision) {
411            (Self::Acp, ApprovalDecision::AllowOnce) => &["allow_once"],
412            (Self::Acp, ApprovalDecision::AllowAlways) => &["allow_always"],
413            (Self::Acp, ApprovalDecision::Deny) => &["reject_once", "reject_always"],
414            (Self::Opencode, ApprovalDecision::AllowOnce) => &["once"],
415            (Self::Opencode, ApprovalDecision::AllowAlways) => &["always"],
416            (Self::Opencode, ApprovalDecision::Deny) => &["reject"],
417            (Self::ClaudeCode, ApprovalDecision::AllowOnce) => &["allow"],
418            (Self::ClaudeCode, ApprovalDecision::AllowAlways) => &[],
419            (Self::ClaudeCode, ApprovalDecision::Deny) => &["deny"],
420            (Self::SupercodeFrontend, ApprovalDecision::AllowOnce) => &["allow"],
421            (Self::SupercodeFrontend, ApprovalDecision::AllowAlways) => &["allow_for_session"],
422            (Self::SupercodeFrontend, ApprovalDecision::Deny) => &["deny"],
423            (Self::Codex, _) => &[],
424        }
425    }
426
427    /// The reply envelope this door carries a chosen option token in.
428    ///
429    /// `None` for a door with no enumerated answers — nothing is guessed.
430    fn reply(self, option_id: &str) -> Option<Value> {
431        match self {
432            // ACP: the client answers by SELECTING one of the request's own
433            // optionIds. This exact envelope cleared a real hermes 0.21.0
434            // permission request in
435            // `docs/interop/research/orch9-hermes-approval-receipt-2026-09-03.json`.
436            Self::Acp => Some(serde_json::json!({
437                "outcome": {"outcome": "selected", "optionId": option_id},
438            })),
439            // opencode: the reply word, POSTed to
440            // `/session/{id}/permissions/{permissionID}` by the adapter.
441            Self::Opencode => Some(serde_json::json!({"response": option_id})),
442            // supercode's own broker takes the decision by name
443            // (`crate::runtime::supercode_http::frontend_response`).
444            Self::SupercodeFrontend => Some(serde_json::json!({"decision": option_id})),
445            // Claude Code: the permission handler's result. `deny` must carry
446            // a `message` — measured against claude 2.1.258, which refuses a
447            // bare `{"behavior":"deny"}` with "Expected {behavior: 'allow',
448            // updatedInput?: object} or {behavior: 'deny', message: string}".
449            // Recorded in
450            // `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`.
451            Self::ClaudeCode if option_id == "deny" => Some(serde_json::json!({
452                "behavior": "deny",
453                "message": "denied through supercode approvals",
454            })),
455            Self::ClaudeCode => Some(serde_json::json!({"behavior": option_id})),
456            Self::Codex => None,
457        }
458    }
459}
460
461/// Translate one caller's choice into the token and envelope a request's own
462/// door accepts.
463///
464/// Kept free of the registry so the whole translation is testable from a
465/// request's options alone.
466pub fn plan_reply(
467    door: ApprovalDoor,
468    options: &[ApprovalOption],
469    choice: &ApprovalChoice,
470) -> Result<(String, Value), ApprovalResolveError> {
471    if options.is_empty() {
472        return Err(ApprovalResolveError::NoOptions {
473            door: door.as_str(),
474        });
475    }
476    let offered = || {
477        options
478            .iter()
479            .map(|option| option.id.clone())
480            .collect::<Vec<_>>()
481    };
482    let chosen = match choice {
483        // An explicit token is passed through, but only after the request
484        // itself is confirmed to offer it.
485        ApprovalChoice::Option(option_id) => options
486            .iter()
487            .find(|option| &option.id == option_id)
488            .ok_or_else(|| ApprovalResolveError::NotOffered {
489                asked: option_id.clone(),
490                offered: offered(),
491            })?,
492        ApprovalChoice::Decision(decision) => door
493            .spellings(*decision)
494            .iter()
495            .find_map(|spelling| {
496                options.iter().find(|option| {
497                    option.kind.as_deref() == Some(*spelling) || option.id == *spelling
498                })
499            })
500            .ok_or_else(|| ApprovalResolveError::NotOffered {
501                asked: decision.as_str().to_string(),
502                offered: offered(),
503            })?,
504    };
505    let response = door
506        .reply(&chosen.id)
507        .ok_or(ApprovalResolveError::NoOptions {
508            door: door.as_str(),
509        })?;
510    Ok((chosen.id.clone(), response))
511}
512
513/// One live protocol request as the classifier reads it off the wire.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct LiveRequest {
516    /// Native JSON id the harness expects back through `runtimes.respond`.
517    pub request_id: Value,
518    /// Protocol door that raised it.
519    pub door: ApprovalDoor,
520    /// Session the request names, when it names one.
521    pub session_id: Option<String>,
522    /// One-line description of what is being asked about.
523    pub subject: String,
524    /// Answers the door accepts.
525    pub options: Vec<ApprovalOption>,
526}
527
528/// Recognize a permission/approval request in one live runtime event.
529///
530/// Returns `None` for every other event, including the responses that answer
531/// these requests. The three recognized shapes are exactly the ones the
532/// adapters in [`crate::runtime`] surface; each is matched on its own
533/// protocol's spelling rather than on a guess about the harness.
534pub fn classify_live_request(kind: &str, payload: &Value) -> Option<LiveRequest> {
535    match kind {
536        // ACP `session/request_permission` (hermes, openclaw, grok, gemini,
537        // goose, and supercode's own ACP server).
538        "session/request_permission" => acp_request(payload),
539        // opencode's bus event; the reply goes to
540        // `/session/{id}/permissions/{permissionID}`.
541        "permission.asked" => opencode_request(payload),
542        // supercode's own runtime, joined over the HTTP frontend: the
543        // request broker publishes `{"type":"request","request":{…}}` and
544        // `runtimes.respond` answers it by the same integer id
545        // (`crate::runtime::supercode_http`).
546        "request" => supercode_request(payload),
547        // Claude Code's stream-json control channel. Only the `can_use_tool`
548        // subtype is a permission request; every other control_request the
549        // CLI can raise is left alone.
550        "control_request" => claude_code_request(payload),
551        // Codex app-server / mcp-server reverse requests
552        // (`execCommandApproval`, `applyPatchApproval`). A notification with
553        // the same name is not a request: only a JSON-RPC message carrying an
554        // `id` can be answered.
555        _ if kind.ends_with("Approval") => codex_request(payload),
556        _ => None,
557    }
558}
559
560fn request_id(payload: &Value) -> Option<Value> {
561    payload
562        .get("id")
563        .filter(|id| !id.is_null())
564        .filter(|id| id.is_string() || id.is_number())
565        .cloned()
566}
567
568fn acp_request(payload: &Value) -> Option<LiveRequest> {
569    let request_id = request_id(payload)?;
570    let params = payload.get("params").unwrap_or(&Value::Null);
571    let tool_call = params.get("toolCall");
572    let subject = tool_call
573        .and_then(|call| call.get("title"))
574        .and_then(Value::as_str)
575        .map(str::to_string)
576        .or_else(|| {
577            tool_call
578                .and_then(|call| call.get("rawInput"))
579                .and_then(command_line)
580        })
581        .or_else(|| {
582            tool_call
583                .and_then(|call| call.get("kind"))
584                .and_then(Value::as_str)
585                .map(str::to_string)
586        })
587        .unwrap_or_else(|| "permission request".to_string());
588    let options = params
589        .get("options")
590        .and_then(Value::as_array)
591        .map(|options| {
592            options
593                .iter()
594                .filter_map(|option| {
595                    Some(ApprovalOption {
596                        id: option.get("optionId").and_then(Value::as_str)?.to_string(),
597                        label: option
598                            .get("name")
599                            .and_then(Value::as_str)
600                            .map(str::to_string),
601                        kind: option
602                            .get("kind")
603                            .and_then(Value::as_str)
604                            .map(str::to_string),
605                    })
606                })
607                .collect()
608        })
609        .unwrap_or_default();
610    Some(LiveRequest {
611        request_id,
612        door: ApprovalDoor::Acp,
613        session_id: params
614            .get("sessionId")
615            .and_then(Value::as_str)
616            .map(str::to_string),
617        subject: one_line(&subject),
618        options,
619    })
620}
621
622fn opencode_request(payload: &Value) -> Option<LiveRequest> {
623    let properties = payload.get("properties").unwrap_or(payload);
624    let permission = properties
625        .get("permission")
626        .filter(|value| value.is_object())
627        .unwrap_or(properties);
628    let id = permission.get("id").and_then(Value::as_str)?;
629    let subject = permission
630        .get("title")
631        .and_then(Value::as_str)
632        .or_else(|| permission.get("pattern").and_then(Value::as_str))
633        .or_else(|| permission.get("type").and_then(Value::as_str))
634        .unwrap_or("permission request");
635    Some(LiveRequest {
636        request_id: Value::String(id.to_string()),
637        door: ApprovalDoor::Opencode,
638        session_id: permission
639            .get("sessionID")
640            .and_then(Value::as_str)
641            .map(str::to_string),
642        subject: one_line(subject),
643        // opencode's own reply vocabulary at the pin
644        // (`docs/composable-harness/inventory/opencode.md` §"Ask/approve
645        // flow": clients reply `once | always | reject`).
646        options: ["once", "always", "reject"]
647            .into_iter()
648            .map(ApprovalOption::bare)
649            .collect(),
650    })
651}
652
653fn supercode_request(payload: &Value) -> Option<LiveRequest> {
654    let request = payload.get("request")?;
655    // Only approvals: an MCP elicitation travels the same envelope but is a
656    // form to fill in, not a permission to grant.
657    if request.get("kind").and_then(Value::as_str) != Some("approval") {
658        return None;
659    }
660    let request_id = request
661        .get("id")
662        .filter(|id| id.is_number())
663        .cloned()
664        .filter(|id| !id.is_null())?;
665    let inner = request.get("payload").unwrap_or(&Value::Null);
666    let subject = inner
667        .get("subject")
668        .and_then(Value::as_str)
669        .filter(|subject| !subject.is_empty())
670        .or_else(|| inner.get("tool").and_then(Value::as_str))
671        .unwrap_or("permission request");
672    Some(LiveRequest {
673        request_id,
674        door: ApprovalDoor::SupercodeFrontend,
675        // A request raised by a background child names that child; a request
676        // from the parent's own loop names nothing beyond the runtime.
677        session_id: inner
678            .get("child_agent_id")
679            .and_then(Value::as_str)
680            .map(str::to_string),
681        subject: one_line(subject),
682        // The decisions `runtimes.respond` accepts on this door
683        // (`crate::runtime::supercode_http::frontend_response`).
684        options: FRONTEND_DECISIONS
685            .into_iter()
686            .map(ApprovalOption::bare)
687            .collect(),
688    })
689}
690
691/// Claude Code's `can_use_tool` control request.
692///
693/// Ground truth (claude 2.1.258, recorded live in
694/// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`): with
695/// `--permission-prompt-tool stdio` the CLI writes
696/// `{"type":"control_request","request_id":"<uuid>","request":{"subtype":"can_use_tool","tool_name":"Bash","display_name":"Bash","input":{…},"description":…,"permission_suggestions":[…],"blocked_path":…,"tool_use_id":"toolu_…"}}`
697/// and blocks the turn until a `control_response` answers that `request_id`.
698fn claude_code_request(payload: &Value) -> Option<LiveRequest> {
699    let request = payload.get("request")?;
700    if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
701        return None;
702    }
703    let request_id = payload
704        .get("request_id")
705        .filter(|id| id.is_string())
706        .cloned()?;
707    let tool = request
708        .get("tool_name")
709        .and_then(Value::as_str)
710        .or_else(|| request.get("display_name").and_then(Value::as_str))
711        .unwrap_or("tool");
712    let detail = request
713        .get("input")
714        .and_then(command_line)
715        .or_else(|| {
716            request
717                .get("description")
718                .and_then(Value::as_str)
719                .map(str::to_string)
720        })
721        .or_else(|| {
722            request
723                .get("blocked_path")
724                .and_then(Value::as_str)
725                .map(str::to_string)
726        });
727    let subject = match detail {
728        Some(detail) if !detail.is_empty() => format!("{tool} {detail}"),
729        _ => tool.to_string(),
730    };
731    Some(LiveRequest {
732        request_id,
733        door: ApprovalDoor::ClaudeCode,
734        // The request names the blocked tool call, never a session: the
735        // connection's own runtime id is the session identity here.
736        session_id: None,
737        subject: one_line(&subject),
738        // The two `behavior` values the CLI's permission-result validator
739        // accepts; see [`ApprovalDoor::spellings`].
740        options: CLAUDE_CODE_BEHAVIORS
741            .into_iter()
742            .map(ApprovalOption::bare)
743            .collect(),
744    })
745}
746
747fn codex_request(payload: &Value) -> Option<LiveRequest> {
748    let request_id = request_id(payload)?;
749    let params = payload.get("params").unwrap_or(&Value::Null);
750    let subject = params
751        .get("command")
752        .and_then(command_line)
753        .or_else(|| {
754            params
755                .get("fileChanges")
756                .and_then(Value::as_object)
757                .map(|changes| {
758                    let files = changes.keys().cloned().collect::<Vec<_>>().join(", ");
759                    if files.is_empty() {
760                        "apply patch".to_string()
761                    } else {
762                        format!("apply patch: {files}")
763                    }
764                })
765        })
766        .or_else(|| {
767            params
768                .get("reason")
769                .and_then(Value::as_str)
770                .map(str::to_string)
771        })
772        .unwrap_or_else(|| "approval request".to_string());
773    Some(LiveRequest {
774        request_id,
775        door: ApprovalDoor::Codex,
776        session_id: ["threadId", "conversationId", "sessionId"]
777            .into_iter()
778            .find_map(|key| params.get(key).and_then(Value::as_str))
779            .map(str::to_string),
780        subject: one_line(&subject),
781        // The Codex reverse request does not enumerate its answers, and this
782        // module does not invent a vocabulary for it.
783        options: Vec::new(),
784    })
785}
786
787/// A command as one line, whether the protocol sends a string or an argv.
788fn command_line(value: &Value) -> Option<String> {
789    match value {
790        Value::String(text) => Some(text.clone()),
791        Value::Array(parts) => {
792            let joined = parts
793                .iter()
794                .filter_map(Value::as_str)
795                .collect::<Vec<_>>()
796                .join(" ");
797            (!joined.is_empty()).then_some(joined)
798        }
799        Value::Object(object) => object
800            .get("command")
801            .or_else(|| object.get("cmd"))
802            .and_then(command_line),
803        _ => None,
804    }
805}
806
807fn one_line(text: &str) -> String {
808    let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
809    if flattened.is_empty() {
810        "permission request".to_string()
811    } else {
812        flattened
813    }
814}
815
816/// Render a native JSON request id into the stable id segment used by
817/// [`ApprovalRow::id`].
818fn id_segment(request_id: &Value) -> String {
819    match request_id {
820        Value::String(text) => text.clone(),
821        other => other.to_string(),
822    }
823}
824
825#[derive(Debug, Clone)]
826struct LiveEntry {
827    request_id: Value,
828    door: ApprovalDoor,
829    harness: HarnessId,
830    runtime_id: String,
831    session_id: Option<String>,
832    subject: String,
833    options: Vec<ApprovalOption>,
834    requested_at_ms: i64,
835}
836
837/// The live pending requests held by one service's open runtime connections.
838///
839/// A request enters when the connection surfaces it and leaves when it is
840/// answered or the connection goes away. Nothing here survives the process:
841/// a live request only exists while the turn that raised it is blocked.
842#[derive(Debug, Default)]
843pub struct ApprovalRegistry {
844    /// Connection id → the requests still outstanding on it, oldest first.
845    entries: BTreeMap<String, Vec<LiveEntry>>,
846}
847
848impl ApprovalRegistry {
849    /// Empty registry.
850    pub fn new() -> Self {
851        Self::default()
852    }
853
854    /// Record one runtime event if it is a permission/approval request.
855    ///
856    /// Returns `true` when the event was recognized and is now listable. A
857    /// repeat of a request already held is not duplicated.
858    pub fn observe(
859        &mut self,
860        connection: &str,
861        harness: &HarnessId,
862        runtime_id: &str,
863        event: &HarnessEvent,
864        now_ms: i64,
865    ) -> bool {
866        let Some(request) = classify_live_request(&event.kind, &event.payload) else {
867            return false;
868        };
869        let entries = self.entries.entry(connection.to_string()).or_default();
870        if entries
871            .iter()
872            .any(|entry| entry.request_id == request.request_id)
873        {
874            return false;
875        }
876        entries.push(LiveEntry {
877            request_id: request.request_id,
878            door: request.door,
879            harness: harness.clone(),
880            runtime_id: runtime_id.to_string(),
881            session_id: request.session_id,
882            subject: request.subject,
883            options: request.options,
884            requested_at_ms: now_ms,
885        });
886        true
887    }
888
889    /// Drop one request because it was answered.
890    pub fn answered(&mut self, connection: &str, request_id: &Value) -> bool {
891        let Some(entries) = self.entries.get_mut(connection) else {
892            return false;
893        };
894        let before = entries.len();
895        entries.retain(|entry| &entry.request_id != request_id);
896        let removed = entries.len() < before;
897        if entries.is_empty() {
898            self.entries.remove(connection);
899        }
900        removed
901    }
902
903    /// Drop everything held for a connection that closed or failed.
904    pub fn forget(&mut self, connection: &str) {
905        self.entries.remove(connection);
906    }
907
908    /// Whether nothing is outstanding.
909    pub fn is_empty(&self) -> bool {
910        self.entries.values().all(Vec::is_empty)
911    }
912
913    /// Plan the answer to one listed row (ORCH-20).
914    ///
915    /// Looks the row up by the same `id` [`Self::rows`] published, translates
916    /// the caller's choice through the door that raised it, and returns what
917    /// `harness.v1.runtimes.respond` needs. Nothing is sent and nothing is
918    /// forgotten here: the service performs the answer on the existing
919    /// respond path, which is also what drops the row.
920    pub fn resolution(
921        &self,
922        row_id: &str,
923        choice: &ApprovalChoice,
924    ) -> Result<ApprovalResolution, ApprovalResolveError> {
925        // A queued subagent record is addressable but not answerable; say so
926        // rather than reporting it simply missing.
927        if row_id.starts_with(SUBAGENT_ROW_PREFIX) {
928            return Err(ApprovalResolveError::QueuedSubagentRow(row_id.to_string()));
929        }
930        let (connection, entry) = self
931            .entries
932            .iter()
933            .flat_map(|(connection, entries)| entries.iter().map(move |entry| (connection, entry)))
934            .find(|(connection, entry)| {
935                format!("{connection}/{}", id_segment(&entry.request_id)) == row_id
936            })
937            .ok_or_else(|| ApprovalResolveError::UnknownId(row_id.to_string()))?;
938        let (option_id, response) = plan_reply(entry.door, &entry.options, choice)?;
939        Ok(ApprovalResolution {
940            connection: connection.clone(),
941            request_id: entry.request_id.clone(),
942            option_id,
943            response,
944        })
945    }
946
947    /// Every outstanding request as a row, aged against `now_ms`.
948    pub fn rows(&self, now_ms: i64) -> Vec<ApprovalRow> {
949        self.entries
950            .iter()
951            .flat_map(|(connection, entries)| {
952                entries.iter().map(move |entry| ApprovalRow {
953                    id: format!("{connection}/{}", id_segment(&entry.request_id)),
954                    harness: entry.harness.clone(),
955                    kind: ApprovalKind::Live,
956                    status: ApprovalStatus::Pending,
957                    subject: entry.subject.clone(),
958                    session_id: entry.session_id.clone(),
959                    runtime_id: Some(entry.runtime_id.clone()),
960                    requested_at_ms: entry.requested_at_ms,
961                    age_ms: now_ms.saturating_sub(entry.requested_at_ms).max(0),
962                    options: entry.options.clone(),
963                })
964            })
965            .collect()
966    }
967}
968
969/// The answers supercode's own frontend accepts, mirroring
970/// [`crate::frontend::FrontendApprovalDecision`]. Used for both a live
971/// request on a joined supercode runtime and a queued subagent request.
972const FRONTEND_DECISIONS: [&str; 3] = ["allow", "allow_for_session", "deny"];
973
974/// The `behavior` values Claude Code's permission-handler protocol defines.
975///
976/// Its own validator names exactly these two: "Expected {behavior: 'allow',
977/// updatedInput?: object} or {behavior: 'deny', message: string}" (claude
978/// 2.1.258, recorded in
979/// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`).
980const CLAUDE_CODE_BEHAVIORS: [&str; 2] = ["allow", "deny"];
981
982/// Row-id prefix every queued subagent record carries.
983const SUBAGENT_ROW_PREFIX: &str = "supercode/subagent/";
984
985/// supercode's own queued subagent approvals, as uniform rows.
986///
987/// The queue is append-only, so the index is a stable identity within the
988/// process that owns it. A record whose outcome is still unknown is
989/// `pending`; one the parent's own handler already answered carries that
990/// answer, which is why this listing never has to guess (see
991/// [`crate::subagents::QueuedApproval`]).
992pub fn subagent_rows(queued: &[QueuedApproval], now_ms: i64) -> Vec<ApprovalRow> {
993    queued
994        .iter()
995        .enumerate()
996        .map(|(index, record)| {
997            let status = match record.outcome {
998                None => ApprovalStatus::Pending,
999                Some(QueuedApprovalOutcome::Allowed) => ApprovalStatus::Allowed,
1000                Some(QueuedApprovalOutcome::Denied) => ApprovalStatus::Denied,
1001            };
1002            let subject = match record.subject.as_deref() {
1003                Some(subject) if !subject.is_empty() => {
1004                    format!("{} {}", record.tool, subject)
1005                }
1006                _ => record.tool.clone(),
1007            };
1008            ApprovalRow {
1009                id: format!(
1010                    "{SUBAGENT_ROW_PREFIX}{}/{}/{index}",
1011                    record.child_agent_id, record.queued_at_ms
1012                ),
1013                harness: HarnessId::from(HarnessId::SUPERCODE),
1014                kind: ApprovalKind::Live,
1015                status,
1016                subject: one_line(&subject),
1017                session_id: Some(record.child_agent_id.clone()),
1018                runtime_id: None,
1019                requested_at_ms: record.queued_at_ms,
1020                age_ms: now_ms.saturating_sub(record.queued_at_ms).max(0),
1021                options: if status == ApprovalStatus::Pending {
1022                    FRONTEND_DECISIONS
1023                        .into_iter()
1024                        .map(ApprovalOption::bare)
1025                        .collect()
1026                } else {
1027                    Vec::new()
1028                },
1029            }
1030        })
1031        .collect()
1032}
1033
1034/// Whether supercode can list approvals for this harness id at all.
1035///
1036/// The uniform-verb contract: a harness whose runtime cannot carry a
1037/// protocol request is refused by name rather than answered with an empty
1038/// list. An unknown id is refused the same way.
1039pub fn lists_approvals(harness: &str) -> bool {
1040    crate::support::harness_support(harness)
1041        .is_some_and(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
1042}
1043
1044/// Every harness id `approvals.list` accepts, in registry order.
1045pub fn approval_harnesses() -> Vec<String> {
1046    crate::support::harness_support_registry()
1047        .harnesses
1048        .into_iter()
1049        .filter(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
1050        .map(|descriptor| descriptor.id.as_str().to_string())
1051        .collect()
1052}
1053
1054/// Wall-clock now in unix milliseconds.
1055pub fn now_ms() -> i64 {
1056    std::time::SystemTime::now()
1057        .duration_since(std::time::UNIX_EPOCH)
1058        .map(|elapsed| elapsed.as_millis() as i64)
1059        .unwrap_or_default()
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065    use serde_json::json;
1066
1067    fn event(kind: &str, payload: Value) -> HarnessEvent {
1068        HarnessEvent {
1069            sequence: None,
1070            kind: kind.to_string(),
1071            payload,
1072        }
1073    }
1074
1075    fn acp_permission(id: u64, title: &str) -> HarnessEvent {
1076        event(
1077            "session/request_permission",
1078            json!({
1079                "jsonrpc": "2.0",
1080                "id": id,
1081                "method": "session/request_permission",
1082                "params": {
1083                    "sessionId": "acp-session",
1084                    "toolCall": {"toolCallId": "call-1", "title": title, "kind": "execute"},
1085                    "options": [
1086                        {"optionId": "allow_once", "name": "Allow once", "kind": "allow_once"},
1087                        {"optionId": "deny", "name": "Deny", "kind": "reject_once"},
1088                    ],
1089                },
1090            }),
1091        )
1092    }
1093
1094    #[test]
1095    fn acp_permission_requests_carry_subject_session_and_option_ids() {
1096        let request = classify_live_request(
1097            "session/request_permission",
1098            &acp_permission(7, "rm -rf build").payload,
1099        )
1100        .expect("an ACP permission request is recognized");
1101        assert_eq!(request.request_id, json!(7));
1102        assert_eq!(request.session_id.as_deref(), Some("acp-session"));
1103        assert_eq!(request.subject, "rm -rf build");
1104        assert_eq!(
1105            request
1106                .options
1107                .iter()
1108                .map(|option| option.id.as_str())
1109                .collect::<Vec<_>>(),
1110            vec!["allow_once", "deny"],
1111        );
1112    }
1113
1114    #[test]
1115    fn opencode_and_codex_requests_use_their_own_protocol_spellings() {
1116        let opencode = classify_live_request(
1117            "permission.asked",
1118            &json!({
1119                "type": "permission.asked",
1120                "properties": {
1121                    "id": "perm-9",
1122                    "sessionID": "oc-session",
1123                    "title": "git push origin main",
1124                },
1125            }),
1126        )
1127        .expect("an opencode permission ask is recognized");
1128        assert_eq!(opencode.request_id, json!("perm-9"));
1129        assert_eq!(opencode.session_id.as_deref(), Some("oc-session"));
1130        assert_eq!(opencode.subject, "git push origin main");
1131        assert_eq!(
1132            opencode
1133                .options
1134                .iter()
1135                .map(|option| option.id.as_str())
1136                .collect::<Vec<_>>(),
1137            vec!["once", "always", "reject"],
1138        );
1139
1140        let codex = classify_live_request(
1141            "execCommandApproval",
1142            &json!({
1143                "jsonrpc": "2.0",
1144                "id": "req-3",
1145                "method": "execCommandApproval",
1146                "params": {"threadId": "cx-thread", "command": ["cargo", "test"]},
1147            }),
1148        )
1149        .expect("a Codex approval reverse request is recognized");
1150        assert_eq!(codex.subject, "cargo test");
1151        assert_eq!(codex.session_id.as_deref(), Some("cx-thread"));
1152        // Codex does not enumerate its answers on the request; nothing is
1153        // invented here.
1154        assert!(codex.options.is_empty());
1155    }
1156
1157    #[test]
1158    fn a_joined_supercode_runtime_publishes_its_own_request_envelope() {
1159        let request = classify_live_request(
1160            "request",
1161            &json!({
1162                "type": "request",
1163                "request": {
1164                    "id": 4,
1165                    "kind": "approval",
1166                    "payload": {
1167                        "tool": "shell",
1168                        "subject": "cargo publish --dry-run",
1169                        "child_agent_id": "child-2",
1170                    },
1171                },
1172            }),
1173        )
1174        .expect("supercode's own frontend request is recognized");
1175        assert_eq!(request.request_id, json!(4));
1176        assert_eq!(request.subject, "cargo publish --dry-run");
1177        assert_eq!(request.session_id.as_deref(), Some("child-2"));
1178        assert_eq!(
1179            request
1180                .options
1181                .iter()
1182                .map(|option| option.id.as_str())
1183                .collect::<Vec<_>>(),
1184            vec!["allow", "allow_for_session", "deny"],
1185        );
1186        // An elicitation travels the same envelope but is not an approval.
1187        assert!(classify_live_request(
1188            "request",
1189            &json!({"type": "request", "request": {"id": 5, "kind": "elicitation", "payload": {}}})
1190        )
1191        .is_none());
1192    }
1193
1194    /// ORC-2: the frame claude 2.1.258 actually writes, transcribed verbatim
1195    /// from `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`.
1196    fn claude_can_use_tool() -> Value {
1197        json!({
1198            "type": "control_request",
1199            "request_id": "053f8a2d-3445-4011-a259-4261b31c7326",
1200            "request": {
1201                "subtype": "can_use_tool",
1202                "tool_name": "Bash",
1203                "display_name": "Bash",
1204                "input": {"command": "touch probe-artifact.txt", "description": "probe"},
1205                "description": "probe",
1206                "permission_suggestions": [{
1207                    "type": "addRules",
1208                    "rules": [{"toolName": "Bash", "ruleContent": "touch probe-artifact.txt"}],
1209                    "behavior": "allow",
1210                    "destination": "localSettings",
1211                }],
1212                "blocked_path": "/tmp/work/probe-artifact.txt",
1213                "tool_use_id": "toolu_mock_1",
1214            },
1215        })
1216    }
1217
1218    #[test]
1219    fn claude_code_can_use_tool_is_a_live_request_with_the_protocols_two_behaviors() {
1220        let request = classify_live_request("control_request", &claude_can_use_tool())
1221            .expect("a can_use_tool control request is a permission request");
1222        assert_eq!(request.door, ApprovalDoor::ClaudeCode);
1223        assert_eq!(
1224            request.request_id,
1225            json!("053f8a2d-3445-4011-a259-4261b31c7326")
1226        );
1227        assert_eq!(request.subject, "Bash touch probe-artifact.txt");
1228        assert_eq!(
1229            request
1230                .options
1231                .iter()
1232                .map(|option| option.id.as_str())
1233                .collect::<Vec<_>>(),
1234            vec!["allow", "deny"],
1235        );
1236
1237        // Every other control_request the CLI can raise is left alone.
1238        assert!(classify_live_request(
1239            "control_request",
1240            &json!({"type":"control_request","request_id":"x","request":{"subtype":"hook_callback"}})
1241        )
1242        .is_none());
1243        // An interrupt ACK is a control_response, not a request.
1244        assert!(classify_live_request(
1245            "control_response",
1246            &json!({"type":"control_response","response":{"subtype":"success"}})
1247        )
1248        .is_none());
1249    }
1250
1251    /// The uniform decisions translate onto the CLI's own `behavior` values,
1252    /// and `allow_always` — which Claude Code expresses through a separate
1253    /// `updatedPermissions` field rather than a behavior — is refused by name
1254    /// with the two answers that ARE offered.
1255    #[test]
1256    fn claude_code_decisions_translate_onto_the_permission_result_the_cli_accepts() {
1257        let request = classify_live_request("control_request", &claude_can_use_tool()).unwrap();
1258        let (option, reply) = plan_reply(
1259            request.door,
1260            &request.options,
1261            &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1262        )
1263        .unwrap();
1264        assert_eq!(option, "allow");
1265        assert_eq!(reply, json!({"behavior": "allow"}));
1266
1267        let (option, reply) = plan_reply(
1268            request.door,
1269            &request.options,
1270            &ApprovalChoice::Decision(ApprovalDecision::Deny),
1271        )
1272        .unwrap();
1273        assert_eq!(option, "deny");
1274        assert_eq!(reply["behavior"], "deny");
1275        // Measured: claude 2.1.258 refuses a deny with no `message`.
1276        assert!(reply["message"].as_str().is_some_and(|m| !m.is_empty()));
1277
1278        let error = plan_reply(
1279            request.door,
1280            &request.options,
1281            &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1282        )
1283        .unwrap_err();
1284        assert_eq!(
1285            error,
1286            ApprovalResolveError::NotOffered {
1287                asked: "allow_always".into(),
1288                offered: vec!["allow".into(), "deny".into()],
1289            }
1290        );
1291    }
1292
1293    #[test]
1294    fn ordinary_events_and_id_less_notifications_are_not_approvals() {
1295        assert!(classify_live_request(
1296            "session/update",
1297            &json!({"method": "session/update", "params": {}})
1298        )
1299        .is_none());
1300        // A same-named notification carries no id, so nothing can answer it.
1301        assert!(classify_live_request(
1302            "execCommandApproval",
1303            &json!({"method": "execCommandApproval", "params": {}})
1304        )
1305        .is_none());
1306    }
1307
1308    #[test]
1309    fn a_recorded_request_lists_once_and_leaves_when_answered() {
1310        let mut registry = ApprovalRegistry::new();
1311        let harness = HarnessId::from(HarnessId::HERMES);
1312        let event = acp_permission(7, "rm -rf build");
1313        assert!(registry.observe("runtime-1", &harness, "acp-session", &event, 1_000));
1314        // The same request seen twice is one row.
1315        assert!(!registry.observe("runtime-1", &harness, "acp-session", &event, 2_000));
1316
1317        let rows = registry.rows(1_500);
1318        assert_eq!(rows.len(), 1);
1319        assert_eq!(rows[0].id, "runtime-1/7");
1320        assert_eq!(rows[0].harness.as_str(), HarnessId::HERMES);
1321        assert_eq!(rows[0].kind, ApprovalKind::Live);
1322        assert_eq!(rows[0].status, ApprovalStatus::Pending);
1323        assert_eq!(rows[0].age_ms, 500);
1324
1325        assert!(registry.answered("runtime-1", &json!(7)));
1326        assert!(registry.is_empty());
1327        assert!(!registry.answered("runtime-1", &json!(7)));
1328    }
1329
1330    #[test]
1331    fn a_closed_connection_takes_its_requests_with_it() {
1332        let mut registry = ApprovalRegistry::new();
1333        let harness = HarnessId::from(HarnessId::OPENCLAW);
1334        registry.observe(
1335            "runtime-2",
1336            &harness,
1337            "acp-session",
1338            &acp_permission(1, "write src/main.rs"),
1339            10,
1340        );
1341        registry.forget("runtime-2");
1342        assert!(registry.rows(20).is_empty());
1343    }
1344
1345    #[test]
1346    fn queued_subagent_rows_report_the_outcome_the_record_holds() {
1347        let queued = vec![
1348            QueuedApproval {
1349                child_agent_id: "child-1".into(),
1350                tool: "shell".into(),
1351                subject: Some("git push".into()),
1352                queued_at_ms: 100,
1353                outcome: None,
1354            },
1355            QueuedApproval {
1356                child_agent_id: "child-2".into(),
1357                tool: "write_file".into(),
1358                subject: None,
1359                queued_at_ms: 200,
1360                outcome: Some(QueuedApprovalOutcome::Denied),
1361            },
1362        ];
1363        let rows = subagent_rows(&queued, 500);
1364        assert_eq!(rows[0].id, "supercode/subagent/child-1/100/0");
1365        assert_eq!(rows[0].harness.as_str(), HarnessId::SUPERCODE);
1366        assert_eq!(rows[0].status, ApprovalStatus::Pending);
1367        assert_eq!(rows[0].subject, "shell git push");
1368        assert_eq!(rows[0].age_ms, 400);
1369        assert_eq!(rows[0].options.len(), 3);
1370        assert_eq!(rows[1].status, ApprovalStatus::Denied);
1371        assert_eq!(rows[1].subject, "write_file");
1372        // A row nobody can still answer advertises no answers.
1373        assert!(rows[1].options.is_empty());
1374    }
1375
1376    #[test]
1377    fn only_harnesses_whose_runtime_can_answer_are_listed() {
1378        assert!(lists_approvals(HarnessId::HERMES));
1379        assert!(lists_approvals(HarnessId::OPENCLAW));
1380        assert!(lists_approvals(HarnessId::CODEX));
1381        assert!(lists_approvals(HarnessId::OPENCODE));
1382        // ORC-2: Claude Code answers `can_use_tool` through the stream-json
1383        // control channel, so it lists like every other driven door.
1384        assert!(lists_approvals(HarnessId::CLAUDE_CODE));
1385        assert!(!lists_approvals("notaharness"));
1386        let harnesses = approval_harnesses();
1387        assert!(harnesses.iter().any(|id| id == HarnessId::HERMES));
1388        assert!(harnesses.iter().any(|id| id == HarnessId::CLAUDE_CODE));
1389    }
1390
1391    // ---- ORCH-20: translating one decision onto a door's own options ------
1392
1393    #[test]
1394    fn each_door_spells_a_uniform_decision_in_its_own_vocabulary() {
1395        // ACP selects by the request's own `kind`, not by the option id.
1396        let acp = vec![
1397            ApprovalOption {
1398                id: "proceed-once".into(),
1399                label: Some("Allow once".into()),
1400                kind: Some("allow_once".into()),
1401            },
1402            ApprovalOption {
1403                id: "refuse".into(),
1404                label: Some("Deny".into()),
1405                kind: Some("reject_once".into()),
1406            },
1407        ];
1408        let (option, response) = plan_reply(
1409            ApprovalDoor::Acp,
1410            &acp,
1411            &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1412        )
1413        .expect("the request offers an allow-once option");
1414        assert_eq!(option, "proceed-once");
1415        assert_eq!(
1416            response,
1417            json!({"outcome": {"outcome": "selected", "optionId": "proceed-once"}}),
1418        );
1419        let (option, _) = plan_reply(
1420            ApprovalDoor::Acp,
1421            &acp,
1422            &ApprovalChoice::Decision(ApprovalDecision::Deny),
1423        )
1424        .expect("`reject_once` is how ACP spells deny");
1425        assert_eq!(option, "refuse");
1426
1427        // opencode's own reply words, POSTed to its permissions route.
1428        let opencode = ["once", "always", "reject"]
1429            .map(ApprovalOption::bare)
1430            .to_vec();
1431        let (option, response) = plan_reply(
1432            ApprovalDoor::Opencode,
1433            &opencode,
1434            &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1435        )
1436        .expect("opencode offers `always`");
1437        assert_eq!(option, "always");
1438        assert_eq!(response, json!({"response": "always"}));
1439
1440        // supercode's own frontend broker takes the decision by name.
1441        let frontend = FRONTEND_DECISIONS.map(ApprovalOption::bare).to_vec();
1442        let (option, response) = plan_reply(
1443            ApprovalDoor::SupercodeFrontend,
1444            &frontend,
1445            &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1446        )
1447        .expect("the frontend offers `allow_for_session`");
1448        assert_eq!(option, "allow_for_session");
1449        assert_eq!(response, json!({"decision": "allow_for_session"}));
1450    }
1451
1452    #[test]
1453    fn a_decision_the_request_does_not_offer_names_the_ones_it_does() {
1454        let options = vec![
1455            ApprovalOption {
1456                id: "allow_once".into(),
1457                label: None,
1458                kind: Some("allow_once".into()),
1459            },
1460            ApprovalOption {
1461                id: "deny".into(),
1462                label: None,
1463                kind: Some("reject_once".into()),
1464            },
1465        ];
1466        let error = plan_reply(
1467            ApprovalDoor::Acp,
1468            &options,
1469            &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1470        )
1471        .expect_err("this request has no allow-always option");
1472        assert_eq!(
1473            error,
1474            ApprovalResolveError::NotOffered {
1475                asked: "allow_always".into(),
1476                offered: vec!["allow_once".into(), "deny".into()],
1477            },
1478        );
1479        let message = error.to_string();
1480        assert!(message.contains("allow_always"), "{message}");
1481        assert!(message.contains("allow_once, deny"), "{message}");
1482
1483        // An explicit option id is checked against the same list.
1484        let error = plan_reply(
1485            ApprovalDoor::Acp,
1486            &options,
1487            &ApprovalChoice::Option("allow_always".into()),
1488        )
1489        .expect_err("an unoffered token is not passed through");
1490        assert!(matches!(error, ApprovalResolveError::NotOffered { .. }));
1491
1492        // …and an offered one is passed through untranslated.
1493        let (option, _) = plan_reply(
1494            ApprovalDoor::Acp,
1495            &options,
1496            &ApprovalChoice::Option("deny".into()),
1497        )
1498        .expect("`deny` is offered");
1499        assert_eq!(option, "deny");
1500    }
1501
1502    #[test]
1503    fn a_door_that_enumerates_nothing_is_refused_rather_than_guessed_at() {
1504        // Codex's reverse approval request carries no options, and ORCH-9
1505        // invents no vocabulary for it — so neither does resolving.
1506        let error = plan_reply(
1507            ApprovalDoor::Codex,
1508            &[],
1509            &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1510        )
1511        .expect_err("nothing to select");
1512        assert_eq!(error, ApprovalResolveError::NoOptions { door: "codex" });
1513        assert!(
1514            error.to_string().contains("harness.v1.runtimes.respond"),
1515            "{error}"
1516        );
1517    }
1518
1519    #[test]
1520    fn the_registry_plans_an_answer_for_the_row_id_it_published() {
1521        let mut registry = ApprovalRegistry::new();
1522        let harness = HarnessId::from(HarnessId::HERMES);
1523        registry.observe(
1524            "runtime-1",
1525            &harness,
1526            "acp-session",
1527            &acp_permission(7, "rm -rf build"),
1528            1_000,
1529        );
1530        let row_id = registry.rows(1_000)[0].id.clone();
1531        assert_eq!(row_id, "runtime-1/7");
1532
1533        let resolution = registry
1534            .resolution(
1535                &row_id,
1536                &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1537            )
1538            .expect("the listed row plans an answer");
1539        assert_eq!(resolution.connection, "runtime-1");
1540        assert_eq!(resolution.request_id, json!(7));
1541        assert_eq!(resolution.option_id, "allow_once");
1542        assert_eq!(
1543            resolution.response,
1544            json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
1545        );
1546        // Planning is not answering: the row is still listed.
1547        assert_eq!(registry.rows(1_000).len(), 1);
1548
1549        let error = registry
1550            .resolution(
1551                "runtime-1/999",
1552                &ApprovalChoice::Decision(ApprovalDecision::Deny),
1553            )
1554            .expect_err("no such row");
1555        assert_eq!(
1556            error,
1557            ApprovalResolveError::UnknownId("runtime-1/999".into())
1558        );
1559
1560        // A queued subagent record is addressable but not answerable here.
1561        let error = registry
1562            .resolution(
1563                "supercode/subagent/child-1/100/0",
1564                &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1565            )
1566            .expect_err("an audit record is not a door");
1567        assert!(matches!(
1568            error,
1569            ApprovalResolveError::QueuedSubagentRow(ref id)
1570                if id == "supercode/subagent/child-1/100/0"
1571        ));
1572        assert!(error.to_string().contains("audit trail"), "{error}");
1573    }
1574
1575    #[test]
1576    fn a_decision_parses_from_both_the_wire_and_the_cli_spelling() {
1577        assert_eq!(
1578            ApprovalDecision::parse("allow-once"),
1579            Some(ApprovalDecision::AllowOnce)
1580        );
1581        assert_eq!(
1582            ApprovalDecision::parse("ALLOW_ALWAYS"),
1583            Some(ApprovalDecision::AllowAlways)
1584        );
1585        assert_eq!(
1586            ApprovalDecision::parse("deny"),
1587            Some(ApprovalDecision::Deny)
1588        );
1589        assert_eq!(ApprovalDecision::parse("maybe"), None);
1590        assert_eq!(
1591            serde_json::to_value(ApprovalDecision::AllowAlways).unwrap(),
1592            json!("allow_always"),
1593        );
1594    }
1595
1596    #[test]
1597    fn a_query_filters_by_harness_and_by_session() {
1598        let rows = subagent_rows(
1599            &[QueuedApproval {
1600                child_agent_id: "child-1".into(),
1601                tool: "shell".into(),
1602                subject: None,
1603                queued_at_ms: 1,
1604                outcome: None,
1605            }],
1606            2,
1607        );
1608        let query = ApprovalsQuery {
1609            harness: Some(HarnessId::SUPERCODE.into()),
1610            session: Some("child-1".into()),
1611        };
1612        assert!(query.matches(&rows[0]));
1613        let other = ApprovalsQuery {
1614            harness: Some(HarnessId::HERMES.into()),
1615            session: None,
1616        };
1617        assert!(!other.matches(&rows[0]));
1618    }
1619}