Skip to main content

agentmux_srv/server/
actor.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Whether the actor a request names is the agent that sent it — identity
5//! M4a-2 (`docs/specs/SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md`
6//! §6.5.3, §6.5.6).
7//!
8//! An actor site is a handler whose request says who is acting: a memory
9//! owner, a global-memory writer, a work claimer or creator, an inject or bus
10//! sender, a cron creator, a UI-automation signer (§6.5.2 item 4). When the
11//! request is [`Caller::Agent`], the named actor is checked against that
12//! UID's own row, and one of these is counted per site:
13//!
14//! - `m4.actor_mismatch.<site>` — the name is none of the row's {slug,
15//!   display name, `instance_name`}, matched as the M3 resolver matches
16//!   (`Store::agents_matching_name`): slug exactly, the others ASCII
17//!   case-insensitively. A name that would not select the caller's row.
18//! - `m4.actor_ambiguous.<site>` — the name selects the caller's row, but not
19//!   by its slug, and also selects another row — any row, a template
20//!   included: the colliding-name case, in which a slug-keyed consumer
21//!   (memory) resolves to the *other* agent, and the template-stub case of
22//!   #3573.
23//! - `m4.actor_absent.<site>` — an attributed request that names no actor.
24//! - `m4.actor_unchecked.<site>` — the caller's row could not be read.
25//!
26//! **Never refused, never awaited, and nothing is written:** the check runs
27//! detached on the blocking pool; the handler does not await it, though it
28//! contends for the store's connection lock like any store read (§7); M4c
29//! dual-writes the UID. This only measures how often the name and the token
30//! disagree before anything relies on the token. Unattributed requests are not
31//! checked — there is no row to compare with, and most are the UI (§6.5.1).
32
33use crate::backend::storage::agents::AgentNameMatch;
34use crate::backend::storage::error::StoreError;
35use crate::backend::storage::store::Store;
36
37use super::caller::Caller;
38use super::AppState;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub(crate) enum Outcome {
42    Mismatch,
43    Ambiguous,
44    Absent,
45    Unchecked,
46}
47
48macro_rules! actor_sites {
49    ($($site:ident => $name:literal,)*) => {
50        /// Where the actor name came from — the `<site>` in the counters.
51        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52        pub(crate) enum ActorSite {
53            $($site,)*
54        }
55
56        impl ActorSite {
57            pub(crate) fn counter(self, outcome: Outcome) -> &'static str {
58                match (self, outcome) {
59                    $(
60                        (ActorSite::$site, Outcome::Mismatch) => concat!("m4.actor_mismatch.", $name),
61                        (ActorSite::$site, Outcome::Ambiguous) => concat!("m4.actor_ambiguous.", $name),
62                        (ActorSite::$site, Outcome::Absent) => concat!("m4.actor_absent.", $name),
63                        (ActorSite::$site, Outcome::Unchecked) => concat!("m4.actor_unchecked.", $name),
64                    )*
65                }
66            }
67        }
68    };
69}
70
71actor_sites! {
72    MemoryList => "memory_list",
73    MemoryRead => "memory_read",
74    MemoryWrite => "memory_write",
75    MemoryHistory => "memory_history",
76    MemoryDiff => "memory_diff",
77    MemoryRevert => "memory_revert",
78    GlobalMemoryWrite => "globalmemory_write",
79    GlobalMemoryRevert => "globalmemory_revert",
80    IdentityAccounts => "identity_accounts",
81    PresetGet => "preset_get",
82    IdentityValidate => "identity_validate",
83    HistorySearch => "history_search",
84    WorkEnqueue => "work_enqueue",
85    WorkClaim => "work_claim",
86    WorkHeartbeat => "work_heartbeat",
87    WorkComplete => "work_complete",
88    WorkRelease => "work_release",
89    CronCreate => "cron_create",
90    Inject => "inject",
91    SupervisorDecision => "supervisor_decision",
92    BusSend => "bus_send",
93    BusInject => "bus_inject",
94    BusBroadcast => "bus_broadcast",
95    UiAuth => "ui_auth",
96}
97
98/// Whether `actor` would select `row` by name (see the module doc).
99pub(crate) fn names_the_row(row: &AgentNameMatch, actor: &str) -> bool {
100    let actor = actor.trim();
101    !actor.is_empty()
102        && (actor == row.slug
103            || actor.eq_ignore_ascii_case(&row.name)
104            || actor.eq_ignore_ascii_case(&row.instance_name))
105}
106
107/// What to count for `actor` against the caller's `row`; `None` when the
108/// name is the caller's own. `rows_named` lists every row the name selects,
109/// templates included (`Store::rows_answering_to`); it is asked only when
110/// the name selects the caller's row other than by its slug.
111pub(crate) fn classify(
112    row: &AgentNameMatch,
113    actor: &str,
114    rows_named: impl FnOnce(&str) -> Result<Vec<AgentNameMatch>, StoreError>,
115) -> Option<Outcome> {
116    let actor = actor.trim();
117    if actor == row.slug {
118        return None;
119    }
120    if !names_the_row(row, actor) {
121        return Some(Outcome::Mismatch);
122    }
123    match rows_named(actor) {
124        Ok(rows) if rows.iter().any(|r| r.id != row.id) => Some(Outcome::Ambiguous),
125        Ok(_) => None,
126        Err(_) => Some(Outcome::Unchecked),
127    }
128}
129
130/// Identity M4d-4: whether `uid`'s own row has `name` as its slug, exactly
131/// (spec §6.5.10 rule ii: never its display or instance name), so a request
132/// carrying `uid`'s token under `name` is that agent by its token.
133pub(crate) fn uid_has_slug(store: &Store, uid: &str, name: &str) -> bool {
134    matches!(store.agent_names_by_id(uid), Ok(Some(row)) if !row.slug.is_empty() && row.slug == name)
135}
136
137/// Identity M4d-4: whether a host-tier jekt is from the agent it claims by
138/// its token (`audit_source_uid` is the request's Caller). Far-side receivers
139/// have no Caller and keep checking the signature, which is still sent.
140pub(crate) fn jekt_sender_by_token(store: &Store, req: &crate::backend::reactive::InjectionRequest, claimed: &str) -> bool {
141    req.delivery_tier.as_deref() == Some("host")
142        && !req.audit_source_uid.is_empty()
143        && uid_has_slug(store, &req.audit_source_uid, claimed)
144}
145
146/// Identity M4d-4: the pane of a caller whose token names it under its own
147/// slug, from its UID's registration, with no key to check. `None` (today's
148/// name path) for anyone else, or before the UID registers.
149pub(crate) fn own_pane_by_token(store: &Store, caller: Option<&Caller>, name: &str) -> Option<String> {
150    let uid = caller.and_then(Caller::uid)?;
151    if !uid_has_slug(store, uid, name) {
152        return None;
153    }
154    crate::backend::reactive::handler::get_global_handler().block_for_uid(uid)
155}
156
157/// Count `actor` against the caller's row at `site`. A no-op for an
158/// Unattributed caller. Never fails and is never awaited: the store reads
159/// run detached on the blocking pool (inline under test, so tests can read
160/// the counters right after the request).
161pub(crate) fn check_actor(
162    state: &AppState,
163    caller: Option<&Caller>,
164    site: ActorSite,
165    actor: Option<&str>,
166) {
167    let Some(uid) = caller.and_then(Caller::uid) else {
168        return;
169    };
170    let Some(actor) = actor.map(str::trim).filter(|a| !a.is_empty()) else {
171        crate::backend::agent_resolve::record_uid_fallback(site.counter(Outcome::Absent));
172        return;
173    };
174    let (mstore, uid, actor) = (state.mstore.clone(), uid.to_string(), actor.to_string());
175    let run = move || count_against_row(&mstore, &uid, site, &actor);
176    #[cfg(not(test))]
177    drop(tokio::task::spawn_blocking(run));
178    #[cfg(test)]
179    run();
180}
181
182fn count_against_row(store: &Store, uid: &str, site: ActorSite, actor: &str) {
183    let outcome = match store.agent_names_by_id(uid) {
184        Ok(Some(row)) => classify(&row, actor, |name| store.rows_answering_to(name)),
185        Ok(None) | Err(_) => Some(Outcome::Unchecked),
186    };
187    let Some(outcome) = outcome else {
188        return;
189    };
190    let counter = site.counter(outcome);
191    crate::backend::agent_resolve::record_uid_fallback(counter);
192    tracing::debug!(
193        counter,
194        caller_uid = uid,
195        actor = %actor.chars().take(128).collect::<String>(),
196        "identity M4a-2: the request's actor name is not plainly the calling agent's"
197    );
198}
199
200/// A work claim also carries the claimer's UID (`agent_uid`, identity M1a).
201/// A carried UID that is not the token's is counted on its own, since it is
202/// the UID — not the name — that M3 writes to `claimed_by_uid`.
203pub(crate) fn check_carried_uid(caller: Option<&Caller>, carried_uid: &str) {
204    let Some(uid) = caller.and_then(Caller::uid) else {
205        return;
206    };
207    let carried = carried_uid.trim();
208    if !carried.is_empty() && carried != uid {
209        crate::backend::agent_resolve::record_uid_fallback("m4.actor_uid_mismatch.work_claim");
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn row(id: &str, name: &str, slug: &str, instance_name: &str) -> AgentNameMatch {
218        AgentNameMatch {
219            id: id.into(),
220            name: name.into(),
221            slug: slug.into(),
222            instance_name: instance_name.into(),
223        }
224    }
225
226    fn only(
227        rows: Vec<AgentNameMatch>,
228    ) -> impl FnOnce(&str) -> Result<Vec<AgentNameMatch>, StoreError> {
229        move |_| Ok(rows)
230    }
231
232    #[test]
233    fn a_name_the_row_answers_to_matches() {
234        let r = row("uid-y", "AgentY", "agenty", "Yardbird");
235        assert!(names_the_row(&r, "agenty"));
236        assert!(names_the_row(&r, " agenty "));
237        assert!(
238            names_the_row(&r, "AGENTY"),
239            "display name is case-insensitive"
240        );
241        assert!(names_the_row(&r, "yardbird"));
242        assert!(!names_the_row(&r, "agentz"));
243        assert!(!names_the_row(&r, ""));
244        // Slug is exact, as in the M3 resolver.
245        let r = row("uid-y", "Why", "agenty", "");
246        assert!(!names_the_row(&r, "AGENTY"));
247        assert!(
248            !names_the_row(&r, ""),
249            "an empty instance name matches nothing"
250        );
251    }
252
253    #[test]
254    fn the_callers_own_slug_is_never_counted_and_asks_nothing() {
255        let r = row("uid-y2", "AGENTY", "agenty-2", "");
256        assert_eq!(classify(&r, "agenty-2", |_| panic!("not asked")), None);
257    }
258
259    #[test]
260    fn a_name_that_is_not_the_callers_is_a_mismatch() {
261        let r = row("uid-y2", "AGENTY", "agenty-2", "");
262        assert_eq!(
263            classify(&r, "agentz", |_| panic!("not asked")),
264            Some(Outcome::Mismatch)
265        );
266    }
267
268    /// The colliding-names fixture: the second agent ("AGENTY", agenty-2)
269    /// sends the first one's slug `agenty`. It matches the second agent's
270    /// display name, so it is no mismatch — but it selects the first agent
271    /// too, and a slug-keyed consumer resolves it there (M4a-2 review P1).
272    #[test]
273    fn a_colliding_name_that_also_selects_another_row_is_ambiguous() {
274        let first = row("uid-y", "AgentY", "agenty", "");
275        let second = row("uid-y2", "AGENTY", "agenty-2", "");
276        assert_eq!(
277            classify(&second, "agenty", only(vec![first.clone(), second.clone()])),
278            Some(Outcome::Ambiguous)
279        );
280        // "AGENTY" selects both rows by display name, so it is ambiguous too.
281        assert_eq!(
282            classify(&second, "AGENTY", only(vec![first.clone(), second.clone()])),
283            Some(Outcome::Ambiguous)
284        );
285        // A display name no other row holds is the caller's own.
286        let zed = row("uid-z", "Zed", "zed-2", "");
287        assert_eq!(classify(&zed, "zed", only(vec![zed.clone()])), None);
288        assert_eq!(
289            classify(&second, "agenty", |_| Err(StoreError::NotFound)),
290            Some(Outcome::Unchecked)
291        );
292    }
293}