agentmux_srv/muxbus/cloud_subscriber/delivery.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Claim → verify → deliver for one agent's pulled injections: the half of a
5//! pull that comes after the relay answered, shared by the single pull
6//! (`sync_agent_reactive`) and the batch pull (`sync_agents_batched`).
7
8use super::*;
9
10/// One agent's answer to a pending pull, single or batched.
11#[derive(Deserialize)]
12pub(super) struct PendingResp {
13 pub(super) injections: Vec<PendingInj>,
14 // What the relay dropped undelivered since the last pull. Absent
15 // from an older relay: nothing to report.
16 #[serde(default)]
17 pub(super) expired: Vec<delivery_status::notes::ExpiredItem>,
18}
19#[derive(Deserialize)]
20pub(super) struct PendingInj {
21 id: String,
22 source_agent: Option<String>,
23 message: String,
24 priority: Option<String>,
25 // When the relay received it (RFC 3339), for marking a late delivery.
26 #[serde(default)]
27 created_at: Option<String>,
28 // Present only for messages signed by an AgentMux-operated WAN
29 // sender (currently just the GitHub review-notification consumer,
30 // "reagent") — see agentmux_common::jekt_sign::verify_reagent_jekt.
31 // All four travel together; verification is attempted only when
32 // every one of them deserializes present (see below).
33 #[serde(default)]
34 reagent_sig: Option<String>,
35 #[serde(default)]
36 reagent_key_id: Option<String>,
37 #[serde(default)]
38 reagent_msg_id: Option<String>,
39 #[serde(default)]
40 reagent_ts_secs: Option<i64>,
41 // W3-S carried tuple + the cloud's same-account answer
42 // (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.1). Absent from an
43 // older cloud and on unsigned rows; verified by `wan_verify` below.
44 #[serde(default)]
45 wan_sig: Option<String>,
46 #[serde(default)]
47 wan_msg_id: Option<String>,
48 #[serde(default)]
49 wan_ts_secs: Option<i64>,
50 #[serde(default)]
51 wan_source_agent: Option<String>,
52 #[serde(default)]
53 wan_target_agent: Option<String>,
54 #[serde(default)]
55 wan_source_host: Option<String>,
56 #[serde(default)]
57 wan_source_channel: Option<String>,
58 #[serde(default)]
59 wan_key_fp: Option<String>,
60 #[serde(default)]
61 sender_same_account: Option<bool>,
62}
63#[derive(Deserialize)]
64pub(super) struct AckResp {
65 acknowledged: Vec<String>,
66 delivered_at: String,
67}
68
69/// Claim `body`'s injections for `agent_id` and deliver the ones this call
70/// won: the second half of [`sync_agent_reactive`], shared with
71/// [`sync_agents_batched`]. `agent_token` is the credential the pull used
72/// (`using_per_agent` when it is the agent's own), retried with the shared
73/// token on a rejection exactly as the pull is.
74#[allow(clippy::too_many_arguments)]
75pub(super) async fn claim_and_deliver(
76 base: &str,
77 agent_id: &str,
78 token: &str,
79 http: &reqwest::Client,
80 mstore: &Arc<Store>,
81 handler: &'static crate::backend::reactive::handler::ReactiveHandler,
82 body: PendingResp,
83 mut agent_token: String,
84 mut using_per_agent: bool,
85) -> AgentSyncOutcome {
86 if body.injections.is_empty() {
87 delivery_status::fetched(agent_id, 0, &body.expired);
88 return AgentSyncOutcome::Ok;
89 }
90
91 // Claim BEFORE delivering, not after: this is what prevents
92 // double-delivery when the same agent_id is concurrently registered by
93 // another AgentMux channel on this host (an intentionally-supported
94 // "two seats" workflow — see
95 // docs/specs/SPEC_MUXBUS_CROSS_CHANNEL_DUPLICATE_DELIVERY_2026_07_04.md).
96 // /reactive/ack now performs an atomic pending->delivered transition
97 // server-side; only injection ids that come back in `acknowledged` were
98 // actually won by this call and get delivered locally below. A
99 // previous version of this code delivered first and only acked
100 // successes afterward, which let two concurrent pollers both deliver
101 // the same injection.
102 let all_ids: Vec<String> = body.injections.iter().map(|inj| inj.id.clone()).collect();
103 let ack_url = format!("{}/reactive/ack", base);
104 let claimed: AckResp = loop {
105 // For telling a 409 from before a Take over apart (note_not_holder_since).
106 let claim_resp_started = std::time::Instant::now();
107 let claim_resp = match http
108 .post(&ack_url)
109 .header("Authorization", format!("Bearer {}", agent_token))
110 .header("X-Agent-ID", agent_id)
111 .header("X-Agent-Instance", crate::muxbus::wan_lease::instance_id())
112 .json(&serde_json::json!({ "injection_ids": all_ids }))
113 .send()
114 .await
115 {
116 Ok(r) => r,
117 Err(e) => {
118 tracing::warn!(agent_id = %agent_id, error = %e, "cloud_subscriber: claim request failed");
119 delivery_status::pull_failed(agent_id, "couldn't claim messages from the relay");
120 return AgentSyncOutcome::Ok; // nothing claimed — retried on the next wake/poll
121 }
122 };
123
124 if is_credential_rejected(claim_resp.status()) {
125 if using_per_agent {
126 // Same reasoning as the /reactive/pending branch above:
127 // invalidate and retry THIS claim immediately with the
128 // shared token, rather than reconnecting the whole session
129 // or leaving this agent's already-fetched pending
130 // injections unclaimed until an unrelated broadcast fires.
131 if claim_resp.status() == reqwest::StatusCode::FORBIDDEN {
132 crate::muxbus::agent_credentials::invalidate_binding_mismatched_credential(agent_id, mstore);
133 } else {
134 crate::muxbus::agent_credentials::invalidate_cached_token(agent_id, mstore);
135 }
136 tracing::warn!(
137 agent_id = %agent_id,
138 status = %claim_resp.status(),
139 "cloud_subscriber: per-agent credential rejected on claim — invalidated, retrying with shared token",
140 );
141 agent_token = shared_token_now(mstore, token).await;
142 using_per_agent = false;
143 continue;
144 }
145 return shared_token_rejection_outcome(claim_resp.status(), agent_id);
146 }
147 if claim_resp.status() == reqwest::StatusCode::CONFLICT {
148 // The relay's lease fence on acks (the lease changed hands between
149 // this pull and this claim): same handling as a 409 on the pull —
150 // claim nothing, and fence the local holder (ReAgent P1 on #3746).
151 let body = claim_resp.json::<serde_json::Value>().await.unwrap_or_default();
152 if let Some(where_) = crate::muxbus::wan_lease::note_not_holder_since(agent_id, &body, claim_resp_started) {
153 crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
154 }
155 return AgentSyncOutcome::Ok;
156 }
157 if !claim_resp.status().is_success() {
158 tracing::warn!(
159 status = %claim_resp.status(),
160 agent_id = %agent_id,
161 "cloud_subscriber: claim request non-2xx"
162 );
163 delivery_status::pull_failed(agent_id, format!("the relay answered {} to a message claim", claim_resp.status().as_u16()));
164 return AgentSyncOutcome::Ok; // nothing claimed — retried on the next wake/poll
165 }
166
167 break match claim_resp.json().await {
168 Ok(b) => b,
169 Err(e) => {
170 // The server processed the claim (status was 2xx) before this
171 // body failed to parse — some subset of `all_ids` may now be
172 // flipped to "delivered" server-side with no local delivery
173 // and no way for us to release them, since we don't know
174 // which ids succeeded or their delivered_at stamp (required
175 // by /reactive/release). We deliberately do NOT guess (e.g.
176 // via /reactive/status) and release whatever looks delivered:
177 // some of `all_ids` may have been legitimately claimed and
178 // delivered by a *different* concurrent poller (another
179 // channel/seat racing for the same agent_id), and blindly
180 // releasing those would reintroduce the exact duplicate-
181 // delivery bug this change exists to fix. Logging every
182 // affected id loudly is the safe tradeoff: rare silent
183 // message loss here, never a resurrected duplicate.
184 tracing::error!(
185 agent_id = %agent_id,
186 injection_ids = ?all_ids,
187 error = %e,
188 "cloud_subscriber: parse claim response failed — these injections may be claimed server-side with no local delivery and cannot be safely auto-recovered"
189 );
190 return AgentSyncOutcome::Ok;
191 }
192 };
193 };
194
195 let delivered_at = claimed.delivered_at;
196 let claimed_ids: std::collections::HashSet<String> = claimed.acknowledged.into_iter().collect();
197 let mut delivered_count = 0usize;
198
199 for inj in &body.injections {
200 if !claimed_ids.contains(&inj.id) {
201 // Another concurrent poller (this account's other channel/seat)
202 // already won this claim — expected under the "two seats"
203 // workflow, not an error.
204 continue;
205 }
206
207 // Verify a reagent-signed WAN message (see PendingInj's doc comment)
208 // before delivery. Only attempted when every one of the four
209 // signing fields is present — a partial set (e.g. a sig but no
210 // key_id) is treated the same as "not signed," not "signed but
211 // broken," since a legitimate sender always sends all four
212 // together. TRUST is unaffected either way (WAN stays
213 // unconditionally TRUST=network-claimed). TIER is only FORCED to
214 // sensitive when `reagent_verified` is `Some(false)` — a signature
215 // present but which failed to cryptographically verify (see
216 // InjectionRequest::reagent_verified,
217 // SPEC_JEKT_SENSITIVE_TIER_NARROWING_2026_08_15.md); as of that
218 // narrowing. `verify_trusted_reagent_jekt` yields `Some(true)` only
219 // under the trusted production key; a signature under any other
220 // registered key is `Some(false)`. `req.reagent_key_id` below must still be carried
221 // through from `inj` so the marker's `SIG=` label and the SIG=verified
222 // rule-1b exception render correctly (reagentx P0 on PR #2576 — it
223 // wasn't, so relaxation silently never activated for
224 // the desktop app's WS delivery path despite `reagent_verified`
225 // being computed correctly).
226 //
227 // Anti-replay (reagentx P1 on PR #2570): a captured, validly-signed
228 // (reagent_sig, reagent_key_id, reagent_msg_id, reagent_ts_secs,
229 // message) tuple could otherwise be resubmitted under a brand-new
230 // injection `id` indefinitely and still render SIG=verified — same
231 // class of gap host-tier's `verify_jekt_signature` closed with
232 // `JEKT_SIG_MAX_AGE_SECS`. `REAGENT_SIG_MAX_AGE_SECS` is wider
233 // (600s vs. 300s) because this is real network delivery, not a
234 // same-machine call — matches the review notifications' own
235 // delivery window, so a message
236 // that's still legitimately within its own staleness budget never
237 // spuriously fails signature freshness on top of that.
238 let reagent_verified = match (&inj.reagent_sig, &inj.reagent_key_id, &inj.reagent_msg_id, inj.reagent_ts_secs) {
239 (Some(sig), Some(key_id), Some(msg_id), Some(ts_secs)) => {
240 Some(
241 reagent_sig_is_fresh(ts_secs, now_unix_secs())
242 && agentmux_common::jekt_sign::verify_trusted_reagent_jekt(
243 key_id,
244 msg_id,
245 inj.source_agent.as_deref().unwrap_or(""),
246 agent_id,
247 ts_secs,
248 &inj.message,
249 sig,
250 ),
251 )
252 }
253 _ => None,
254 };
255
256 // W3-S (§2.3): a same-account agent's WAN signature, verified here —
257 // before transcript-request resolution, and against the POLLED
258 // agent, never a row field. Every "couldn't check" is `None` and the
259 // message is delivered as today; only an active failure is
260 // `Some(false)`.
261 let wan_row = crate::muxbus::wan_verify::CarriedRow {
262 wan_sig: inj.wan_sig.clone(),
263 wan_msg_id: inj.wan_msg_id.clone(),
264 wan_ts_secs: inj.wan_ts_secs,
265 wan_source_agent: inj.wan_source_agent.clone(),
266 wan_target_agent: inj.wan_target_agent.clone(),
267 wan_source_host: inj.wan_source_host.clone(),
268 wan_source_channel: inj.wan_source_channel.clone(),
269 wan_key_fp: inj.wan_key_fp.clone(),
270 sender_same_account: inj.sender_same_account,
271 };
272 let wan_store = crate::backend::storage::wan_identity::global();
273 let wan_verdict = match (&wan_store, &inj.wan_sig) {
274 (Some(wan), Some(_)) => match wan.instance_ensure(&crate::backend::reactive::registry::local_host_label()) {
275 Ok(own) => {
276 let base_url = crate::muxbus::relay::rest_base_url();
277 let dir_token = shared_token_now(mstore, token).await;
278 let dir = crate::muxbus::wan_verify::Directory {
279 base_url: &base_url,
280 http,
281 token: &dir_token,
282 budget: &crate::muxbus::wan_verify::GLOBAL_BUDGET,
283 };
284 crate::muxbus::wan_verify::verify(
285 wan,
286 &own.instance_id,
287 agent_id,
288 inj.source_agent.as_deref().unwrap_or(""),
289 &wan_row,
290 &inj.message,
291 now_unix_secs(),
292 &dir,
293 )
294 .await
295 }
296 Err(e) => crate::muxbus::wan_verify::WanVerdict::unchecked("wan_no_local_instance", e.to_string()),
297 },
298 (None, Some(_)) => crate::muxbus::wan_verify::WanVerdict::unchecked("wan_no_local_store", "no wan.db attached"),
299 (_, None) => crate::muxbus::wan_verify::WanVerdict::default(),
300 };
301 // Every "couldn't check" renders exactly like an unsigned message
302 // (`TRUST=network-claimed`), so say which one happened at a level
303 // that is actually recorded. One line per WAN jekt.
304 if inj.wan_sig.is_some() {
305 let (verified, reason, detail) =
306 (wan_verdict.verified, wan_verdict.reason.unwrap_or(""), wan_verdict.detail.as_deref().unwrap_or(""));
307 if verified == Some(false) {
308 tracing::warn!(injection_id = %inj.id, source = ?inj.source_agent, reason, "wan verify: signature FAILED");
309 } else if !detail.is_empty() {
310 // A check that couldn't run for a known cause (directory
311 // refused or unreachable, no local store): the one line that
312 // reports it, at `warn` — `wan_verify::get_json` no longer
313 // logs each attempt.
314 tracing::warn!(
315 injection_id = %inj.id,
316 source = ?inj.source_agent,
317 verified = ?verified,
318 reason,
319 detail,
320 same_account = ?inj.sender_same_account,
321 "wan verify: outcome"
322 );
323 } else {
324 tracing::info!(
325 injection_id = %inj.id,
326 source = ?inj.source_agent,
327 verified = ?verified,
328 reason,
329 same_account = ?inj.sender_same_account,
330 "wan verify: outcome"
331 );
332 }
333 } else if inj.reagent_sig.is_none() {
334 tracing::info!(injection_id = %inj.id, source = ?inj.source_agent, "wan verify: arrived without a WAN signature");
335 }
336
337 let mut req = InjectionRequest {
338 target_agent: agent_id.to_string(),
339 message: inj.message.clone(),
340 source_agent: inj.source_agent.clone(),
341 request_id: Some(inj.id.clone()),
342 priority: inj.priority.clone(),
343 jekt_tier: None, // auto-detected from keywords
344 delivery_tier: Some("wan".to_string()),
345 forward_hops: 0,
346 reagent_verified,
347 reagent_key_id: inj.reagent_key_id.clone(),
348 wan_verified: wan_verdict.verified,
349 wan_instance: wan_verdict.instance.clone(),
350 wan_reason: wan_verdict.reason.map(str::to_string),
351 wan_detail: wan_verdict.detail.clone(),
352 ..Default::default()
353 };
354 // Phase C (muxspect Phase B/C conversation visibility,
355 // SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md): this WAN
356 // delivery path calls `Handler::inject_message` directly and never
357 // goes through `server/reactive.rs::handle_reactive_inject`/HTTP at
358 // all — without also resolving these two fields HERE, a
359 // WAN-delivered `transcript_request` would silently skip both the
360 // forced-TIER=sensitive rule and the escalate-forcing rule
361 // entirely, not just get a weaker version of them.
362 crate::server::reactive::resolve_transcript_request_tier_fields(mstore, &mut req);
363 // Marked only after every check above has read the message as sent.
364 let created_ms = inj.created_at.as_deref().and_then(delivery_status::notes::parse_created_at);
365 let late = delivery_status::notes::delayed_prefix(created_ms, now_ms());
366 if !late.is_empty() {
367 req.message = format!("{late}{}", req.message);
368 }
369 let delivery = handler.inject_message(req);
370 tracing::debug!(
371 injection_id = %inj.id,
372 agent_id = %agent_id,
373 success = delivery.success,
374 "cloud_subscriber: delivered injection"
375 );
376
377 // W3-S §2.5: only now, after it landed, is this signature "seen" —
378 // a release-and-redeliver after a failed delivery must not read as
379 // a replay.
380 if delivery.success {
381 delivered_count += 1;
382 if let Some(wan) = &wan_store {
383 crate::muxbus::wan_verify::record_delivered(wan, &wan_verdict, &wan_row, now_unix_secs());
384 }
385 }
386
387 if !delivery.success {
388 // We hold the claim but local delivery failed (e.g. agent not
389 // ready) — release it back to pending so it's retried, instead
390 // of silently dropping it now that claiming already marked it
391 // "delivered".
392 let release_url = format!("{}/reactive/release", base);
393 match http
394 .post(&release_url)
395 .header("Authorization", format!("Bearer {}", agent_token))
396 .header("X-Agent-ID", agent_id)
397 .json(&serde_json::json!({
398 "injection_id": inj.id,
399 "delivered_at": delivered_at,
400 }))
401 .send()
402 .await
403 {
404 Ok(r) if r.status().is_success() => {}
405 Ok(r) => {
406 // Claim is stranded as "delivered" with no local
407 // delivery and no retry until this is visible — log
408 // loudly rather than discarding silently.
409 tracing::warn!(
410 injection_id = %inj.id,
411 agent_id = %agent_id,
412 status = %r.status(),
413 "cloud_subscriber: release request non-2xx — injection stranded as delivered, message lost"
414 );
415 }
416 Err(e) => {
417 tracing::warn!(
418 injection_id = %inj.id,
419 agent_id = %agent_id,
420 error = %e,
421 "cloud_subscriber: release request failed — injection stranded as delivered, message lost"
422 );
423 }
424 }
425 }
426 }
427
428 delivery_status::fetched(agent_id, delivered_count, &body.expired);
429 AgentSyncOutcome::Ok
430}