Skip to main content

laser_sdk/agent/
registry.rs

1#[cfg(feature = "query")]
2use crate::agent::clock::{Clock, SystemClock};
3use crate::cursor::Cursor;
4use crate::error::LaserError;
5use crate::laser::Laser;
6use crate::provenance::AgentTopic;
7use crate::types::{AgentId, MintUlid, PrincipalId};
8use laser_wire::agent::{
9    AgentCard, AgentEnvelope, AgentPresence, ConversationId, Health, OPERATION_CARD,
10    OPERATION_QUARANTINE, OPERATION_UNQUARANTINE, RecordId,
11};
12use laser_wire::content::ContentType;
13use laser_wire::framing::decode_named;
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::sync::{Arc, Mutex};
16
17/// How long a paged presence read is reused before the next refresh pages the
18/// connection table again. Presence (an agent's live inbox) changes rarely, so a
19/// short reuse window turns the per-send full-table page into an occasional one
20/// without routing meaningfully stale inboxes.
21#[cfg(feature = "query")]
22const PRESENCE_TTL_MICROS: u64 = 2_000_000;
23const APPLIED_FACT_CAPACITY: usize = 16_384;
24
25// Cards are keyed by the self-asserted envelope source and a card without a ttl
26// never ages out, so the map is capped like the applied-fact set rather than
27// left to grow with every id a registry writer invents.
28const MAX_REGISTERED_CARDS: usize = 4_096;
29const APPLIED_FACT_TTL_MICROS: u64 = 86_400_000_000;
30
31/// The registry read model's persisted state, shared per stream on the connection
32/// so a fresh [`AgentRegistry`] resumes the fold instead of re-reading the whole
33/// registry topic from offset 0 on every send. Folded cards, the quarantine set,
34/// the cursor's resume offsets, and the last paged presence with its read time.
35#[derive(Default)]
36pub(crate) struct RegistryCache {
37    cards: HashMap<AgentId, RegisteredCard>,
38    quarantined: HashSet<AgentId>,
39    offsets: Vec<u64>,
40    presence: HashMap<AgentId, PresenceEntry>,
41    presence_read_at_micros: Option<u64>,
42    /// Record ids of privileged facts already folded, so a captured fact
43    /// republished verbatim (same signed `record` id, new log offset) is dropped
44    /// rather than re-applied. The replay-resistance backstop.
45    applied_facts: HashSet<RecordId>,
46    applied_fact_order: VecDeque<(RecordId, u64)>,
47}
48
49/// A folded presence and the authenticated principal of the connection that
50/// advertised it. The principal is the identity the substrate authenticated (the
51/// trustworthy half). The agent id and inbox inside the presence are the
52/// connection's own claim, so an inbox is only safe to trust as that principal's.
53#[derive(Clone, Debug)]
54pub struct PresenceEntry {
55    pub presence: AgentPresence,
56    pub principal: Option<PrincipalId>,
57}
58
59// The client-metadata discovery read rides the managed binary bridge
60// (`send_raw_with_response`), which is gated on `query`, so its SDK surface is too.
61#[cfg(feature = "query")]
62use laser_wire::clients::{ClientMetadata, ClientMetadataList, ClientMetadataQuery};
63#[cfg(feature = "query")]
64use laser_wire::codes::{
65    AGDX_GET_CLIENTS_METADATA_CODE, AGDX_SET_CLIENT_METADATA_CODE, CLIENT_METADATA_OP_VERSION,
66};
67#[cfg(feature = "query")]
68use laser_wire::framing::encode_named;
69
70/// The shared page cap, so one client-metadata page can never pull an unbounded
71/// list. Mirrors the server clamp.
72#[cfg(feature = "query")]
73const MAX_PAGE_SIZE: usize = laser_wire::limits::MAX_PAGE_SIZE;
74
75/// One agent's latest card, with the time the registry folded it in. A card
76/// older than its [`AgentCard::ttl_micros`] is treated as a dead agent.
77#[derive(Clone, Debug)]
78pub struct RegisteredCard {
79    pub agent: AgentId,
80    pub card: AgentCard,
81    /// When the registry observed this card (epoch micros). The registry's own
82    /// fold time, so a live registry refreshed faster than the ttl keeps a
83    /// healthy agent fresh.
84    pub observed_at_micros: u64,
85}
86
87impl RegisteredCard {
88    /// Whether the card is still fresh at `now_micros`: a card with no ttl never
89    /// expires, otherwise it is fresh while `observed_at + ttl >= now`.
90    pub fn is_fresh(&self, now_micros: u64) -> bool {
91        match self.card.ttl_micros {
92            None => true,
93            Some(ttl) => self.observed_at_micros.saturating_add(ttl) >= now_micros,
94        }
95    }
96
97    /// Whether the card advertises `skill_id`.
98    pub fn serves(&self, skill_id: &str) -> bool {
99        self.card
100            .capabilities
101            .iter()
102            .any(|capability| capability.skill_id == skill_id)
103    }
104
105    /// Whether the agent's advertised health for `skill_id` permits routing: an
106    /// `Unavailable` skill is skipped, everything else (healthy, degraded, or
107    /// unspecified) stays routable, and a skill the card does not advertise is not
108    /// available. The health-aware routing gate, so a self-declared unavailable
109    /// agent is not handed work.
110    pub fn available_for(&self, skill_id: &str) -> bool {
111        self.card
112            .capabilities
113            .iter()
114            .find(|capability| capability.skill_id == skill_id)
115            .is_some_and(|capability| capability.health != Some(Health::Unavailable))
116    }
117}
118
119/// The fused agent registry. Two distinct sources kept apart on purpose: the
120/// durable, replayable **log card registry** (a fold over the
121/// registry topic to the latest card per agent, capability that outlives a brief
122/// disconnect), and the live **connection presence** (what an agent advertises
123/// right now over `AGDX_SET_CLIENT_METADATA`, including the inbox topic routing
124/// resolves to, gone on disconnect). Build it with [`Laser::agent_registry`],
125/// [`refresh`](Self::refresh) to fold new cards, [`refresh_presence`](Self::refresh_presence)
126/// to pull live presence, then resolve by capability and inbox.
127pub struct AgentRegistry<'a> {
128    // Only the `query`-gated presence read uses this. The card fold rides `cursor`.
129    #[cfg_attr(not(feature = "query"), allow(dead_code))]
130    laser: &'a Laser,
131    cursor: Cursor,
132    cards: HashMap<AgentId, RegisteredCard>,
133    /// Live presence keyed by the agent id the connection claims. Seeded from the
134    /// per-stream cache, refreshed by a `query`-gated read no more often than the
135    /// presence ttl, so an [`InboxRoute::Advertised`](crate::agent::InboxRoute::Advertised)
136    /// resolution without a prior refresh simply finds no inbox.
137    presence: HashMap<AgentId, PresenceEntry>,
138    /// Agents an operator has quarantined (folded from `quarantine` records on the
139    /// registry topic). Excluded from capability resolution, so a quarantined agent
140    /// is never routed work even while its card is fresh.
141    quarantined: HashSet<AgentId>,
142    /// Record ids of privileged facts already folded (replay-resistance backstop).
143    applied_facts: HashSet<RecordId>,
144    applied_fact_order: VecDeque<(RecordId, u64)>,
145    /// The shared per-stream state this registry seeded from and writes its fold
146    /// back to, so the next registry resumes instead of re-reading from offset 0.
147    cache: Arc<Mutex<RegistryCache>>,
148    /// When the seeded presence was last paged (epoch micros), so a refresh within
149    /// the ttl reuses it instead of paging the connection table again.
150    #[cfg_attr(not(feature = "query"), allow(dead_code))]
151    presence_read_at_micros: Option<u64>,
152    /// The enrolled-key verifier privileged facts are checked against. When set, a
153    /// quarantine or un-quarantine record is folded only if it carries a signature
154    /// that verifies. Otherwise the registry topic's write access control is the
155    /// sole gate.
156    #[cfg(feature = "sign")]
157    verifier: Option<Arc<crate::sign::KeyRegistry>>,
158}
159
160impl<'a> AgentRegistry<'a> {
161    /// Fold every card published since the last refresh into the registry,
162    /// stamping `now_micros` as the observation time, and write the result back to
163    /// the shared per-stream cache (evicting cards stale at `now_micros`, so the
164    /// cached set stays bounded by live agents). Returns the number of records
165    /// folded. A record that is not a `status`/`card`/`quarantine` envelope, or
166    /// whose body does not decode, is skipped (the registry topic is single-purpose,
167    /// but a stray record must not poison the fold).
168    pub async fn refresh(&mut self, now_micros: u64) -> Result<usize, LaserError> {
169        let messages = self.cursor.poll_iggy().await?;
170        let mut folded = 0;
171        for (_, message) in &messages {
172            let Ok(decoded) = crate::agent::consumer::decode_agent_record(
173                message,
174                laser_wire::agent::features::NONE,
175            ) else {
176                continue;
177            };
178            let Some(envelope) = decoded.envelope else {
179                continue;
180            };
181            let operation = envelope.operation.as_deref();
182            let applied = if operation == Some(OPERATION_QUARANTINE)
183                || operation == Some(OPERATION_UNQUARANTINE)
184            {
185                // A quarantine fact evicts an agent from routing, so it is the one
186                // privileged write here. Three gates, in order. A fact whose signed
187                // `record` id was already folded is a verbatim replay and dropped
188                // (G1). An enrolled verifier must confirm an OPERATOR key valid at
189                // fold time (G2, so an enrolled agent cannot quarantine another).
190                // Without a verifier the registry topic's write access control is
191                // the gate. An authorized fact's id is remembered so its replay is
192                // caught even when it did not change state.
193                if envelope
194                    .record
195                    .is_some_and(|id| self.applied_facts.contains(&id))
196                    || !self.fact_authorized(
197                        &envelope,
198                        decoded.signature_context.as_ref(),
199                        decoded.observed_at_micros,
200                    )
201                {
202                    false
203                } else {
204                    if let Some(id) = envelope.record {
205                        self.applied_facts.insert(id);
206                        self.applied_fact_order
207                            .push_back((id, decoded.observed_at_micros));
208                    }
209                    if operation == Some(OPERATION_QUARANTINE) {
210                        apply_quarantine(&mut self.quarantined, &envelope)
211                    } else {
212                        lift_quarantine(&mut self.quarantined, &envelope)
213                    }
214                }
215            } else {
216                apply_card(&mut self.cards, &envelope, decoded.observed_at_micros)
217            };
218            if applied {
219                folded += 1;
220            }
221        }
222        // A card past its ttl is a dead agent. Drop it so the cached set is bounded
223        // by live agents, not every agent ever seen. A ttl-less card never expires.
224        self.cards.retain(|_, card| card.is_fresh(now_micros));
225        let cutoff = now_micros.saturating_sub(APPLIED_FACT_TTL_MICROS);
226        while self.applied_fact_order.len() > APPLIED_FACT_CAPACITY
227            || self
228                .applied_fact_order
229                .front()
230                .is_some_and(|(_, observed)| *observed < cutoff)
231        {
232            if let Some((id, _)) = self.applied_fact_order.pop_front() {
233                self.applied_facts.remove(&id);
234            }
235        }
236        if let Ok(mut cache) = self.cache.lock() {
237            cache.cards = self.cards.clone();
238            cache.quarantined = self.quarantined.clone();
239            cache.applied_facts = self.applied_facts.clone();
240            cache.applied_fact_order = self.applied_fact_order.clone();
241            cache.offsets = self.cursor.offsets().to_vec();
242        }
243        Ok(folded)
244    }
245
246    /// Whether a privileged registry fact (quarantine, un-quarantine) is
247    /// authorized to fold. With an enrolled verifier it must carry a signature
248    /// that verifies against an enrolled OPERATOR key valid at `now_micros`, so an
249    /// enrolled agent key cannot quarantine another agent. Without a verifier the
250    /// registry topic's write access control is trusted as the gate.
251    #[cfg(feature = "sign")]
252    fn fact_authorized(
253        &self,
254        envelope: &AgentEnvelope,
255        context: Option<&laser_wire::agent::SignatureContext>,
256        observed_at_micros: u64,
257    ) -> bool {
258        match &self.verifier {
259            Some(registry) => context.is_some_and(|context| {
260                registry
261                    .verify_observed_at(envelope, context, observed_at_micros)
262                    .is_ok_and(|verified| verified.kind == crate::sign::KeyKind::Operator)
263            }),
264            None => true,
265        }
266    }
267
268    #[cfg(not(feature = "sign"))]
269    fn fact_authorized(
270        &self,
271        _envelope: &AgentEnvelope,
272        _context: Option<&laser_wire::agent::SignatureContext>,
273        _observed_at_micros: u64,
274    ) -> bool {
275        true
276    }
277
278    /// Every agent with a folded card, newest observation kept, ordered by
279    /// agent id (byte order), so every SDK lists and routes in the same order.
280    pub fn agents(&self) -> impl Iterator<Item = &RegisteredCard> {
281        by_agent_id(self.cards.values()).into_iter()
282    }
283
284    /// The latest card for `agent`, if any.
285    pub fn lookup(&self, agent: &AgentId) -> Option<&RegisteredCard> {
286        self.cards.get(agent)
287    }
288
289    /// Every agent that advertises `skill_id`, is still fresh at `now_micros`, and
290    /// is not advertising itself `Unavailable` for it. The capability-resolution
291    /// primitive the router builds on, so a stale or self-declared-unavailable
292    /// agent is never routed to. Ordered by agent id (byte order), so
293    /// [`RoutePolicy::Any`](crate::agent::RoutePolicy::Any) and ranking ties pick
294    /// the same agent in every SDK.
295    pub fn resolve(&self, skill_id: &str, now_micros: u64) -> Vec<&RegisteredCard> {
296        self.agents()
297            .filter(|card| {
298                card.available_for(skill_id)
299                    && card.is_fresh(now_micros)
300                    && !self.quarantined.contains(&card.agent)
301            })
302            .collect()
303    }
304
305    /// Whether `agent` has been quarantined by an operator.
306    pub fn is_quarantined(&self, agent: &AgentId) -> bool {
307        self.quarantined.contains(agent)
308    }
309
310    /// The inbox topic `agent` advertised in its live presence, or `None` when it
311    /// advertised none (or [`refresh_presence`](Self::refresh_presence) has not
312    /// run). The destination an [`InboxRoute::Advertised`](crate::agent::InboxRoute::Advertised)
313    /// route resolves to, so routing never assumes a shared topic name.
314    pub fn inbox_for(&self, agent: &AgentId) -> Option<&str> {
315        self.presence
316            .get(agent)
317            .and_then(|entry| entry.presence.inbox.as_deref())
318    }
319
320    /// The inbox `agent` advertised, but only if the connection that advertised it
321    /// authenticated as `user_id`. The principal-bound resolve a deployment with an
322    /// enrolled agent-to-principal mapping uses, so a connection cannot route work
323    /// to itself by claiming another principal's agent id. [`inbox_for`](Self::inbox_for)
324    /// is the claim-only resolve for deployments without such a mapping.
325    pub fn inbox_for_principal(&self, agent: &AgentId, principal: PrincipalId) -> Option<&str> {
326        self.presence.get(agent).and_then(|entry| {
327            (entry.principal == Some(principal))
328                .then_some(entry.presence.inbox.as_deref())
329                .flatten()
330        })
331    }
332
333    /// The authenticated principal of the live connection claiming `agent`.
334    /// `None` means no live presence or a substrate that supplied no principal.
335    pub fn principal_for(&self, agent: &AgentId) -> Option<PrincipalId> {
336        self.presence.get(agent).and_then(|entry| entry.principal)
337    }
338
339    /// Pull live connection presence and fold it in, keyed by the agent id each
340    /// connection claims, so [`inbox_for`](Self::inbox_for) can resolve a target
341    /// to where it is consuming right now. Reads every page of the client-metadata
342    /// discovery command and decodes each connection's metadata as an
343    /// [`AgentPresence`], skipping connections whose metadata is absent or is not a
344    /// presence body (the channel is opaque, a non-agent client advertises any
345    /// blob). A later connection claiming the same agent id wins, matching
346    /// last-write presence. Returns the number of presences folded.
347    ///
348    /// Served by Iggy, so it returns a transport error against a
349    /// Iggy server that lacks the command.
350    #[cfg(feature = "query")]
351    pub async fn refresh_presence(&mut self) -> Result<usize, LaserError> {
352        let now = SystemClock.now_micros();
353        // Reuse the seeded presence while it is within the ttl: paging the whole
354        // connection table on every send is the dominant per-send cost, and an
355        // agent's inbox changes rarely.
356        if let Some(read_at) = self.presence_read_at_micros
357            && now.saturating_sub(read_at) < PRESENCE_TTL_MICROS
358        {
359            return Ok(self.presence.len());
360        }
361        let connections = self
362            .laser
363            .client_metadata()
364            .with_metadata_only(true)
365            .all()
366            .await?;
367        let mut fresh = HashMap::new();
368        for connection in connections {
369            if let Some(payload) = connection.metadata.as_deref() {
370                // Bind the presence to the connection's authenticated `user_id`
371                // (not the self-claimed agent string alone), so a connection
372                // cannot advertise an inbox under another principal's agent id.
373                apply_presence(&mut fresh, payload, connection.user_id);
374            }
375        }
376        let folded = fresh.len();
377        self.presence = fresh;
378        self.presence_read_at_micros = Some(now);
379        if let Ok(mut cache) = self.cache.lock() {
380            cache.presence = self.presence.clone();
381            cache.presence_read_at_micros = Some(now);
382        }
383        Ok(folded)
384    }
385}
386
387/// Apply one connection's advertised metadata to the presence map: when it
388/// decodes as an [`AgentPresence`] claiming a valid agent id, store it keyed by
389/// that agent (last write wins). Returns whether a presence was applied. A blob
390/// that is not a presence body is skipped (the metadata channel is opaque). Pure,
391/// so the decode-and-key fold is unit-testable without a live connection.
392#[cfg(feature = "query")]
393fn apply_presence(
394    presence: &mut HashMap<AgentId, PresenceEntry>,
395    metadata: &[u8],
396    user_id: Option<u32>,
397) -> bool {
398    let Ok(body) = decode_named::<AgentPresence>(metadata) else {
399        return false;
400    };
401    // Client metadata is peer-supplied, so the presence caps are enforced here
402    // rather than assumed to have been enforced on the write side.
403    if body.validate().is_err() {
404        return false;
405    }
406    let Ok(agent) = AgentId::try_from(body.agent.as_str()) else {
407        return false;
408    };
409    presence.insert(
410        agent,
411        PresenceEntry {
412            presence: body,
413            principal: user_id.map(PrincipalId::new),
414        },
415    );
416    true
417}
418
419impl Laser {
420    /// Open a read model over the agent card registry topic, seeded from the
421    /// per-stream cache so it resumes the fold instead of re-reading the topic from
422    /// offset 0. Each [`refresh`](AgentRegistry::refresh) folds only what is new and
423    /// writes the result back to the cache.
424    pub fn agent_registry(&self) -> Result<AgentRegistry<'_>, LaserError> {
425        let cache = self.registry_cache()?;
426        let (
427            cards,
428            quarantined,
429            applied_facts,
430            applied_fact_order,
431            presence,
432            offsets,
433            presence_read_at_micros,
434        ) = {
435            let guard = cache
436                .lock()
437                .unwrap_or_else(|poisoned| poisoned.into_inner());
438            (
439                guard.cards.clone(),
440                guard.quarantined.clone(),
441                guard.applied_facts.clone(),
442                guard.applied_fact_order.clone(),
443                guard.presence.clone(),
444                guard.offsets.clone(),
445                guard.presence_read_at_micros,
446            )
447        };
448        let cursor = self
449            .reader(AgentTopic::Registry.topic_string())?
450            .from_offsets(offsets);
451        Ok(AgentRegistry {
452            laser: self,
453            cursor,
454            cards,
455            presence,
456            quarantined,
457            applied_facts,
458            applied_fact_order,
459            cache,
460            presence_read_at_micros,
461            #[cfg(feature = "sign")]
462            verifier: self.registry_verifier(),
463        })
464    }
465
466    /// Publish `source`'s capability card to the registry topic, the write side of
467    /// the log card registry: a `status` record with operation `card` carrying the
468    /// encoded [`AgentCard`]. A registry read model folds the latest card per
469    /// agent, so capability routing can resolve `source`. Re-publish on an interval
470    /// shorter than the card's `ttl_micros` to keep the card fresh, since a card
471    /// older than its ttl reads as a dead agent.
472    pub async fn publish_card(&self, source: AgentId, card: &AgentCard) -> Result<(), LaserError> {
473        let body = laser_wire::framing::encode_named(card)
474            .map_err(|error| LaserError::Codec(format!("encode card: {error}")))?;
475        self.agdx(
476            AgentTopic::Registry,
477            source.wire_id(),
478            ConversationId::mint(),
479        )
480        .status(OPERATION_CARD)
481        .body(body)
482        .content_type(ContentType::Cbor)
483        .send()
484        .await?;
485        Ok(())
486    }
487
488    /// Quarantine `agent`: append a `quarantine` fact to the registry topic as
489    /// `operator`, so every fused registry folds it and excludes the agent from
490    /// capability resolution. Authorized by the registry topic's write access
491    /// control (only an operator may append here, the baseline gate). Where a
492    /// deployment enrolls an operator-key verifier
493    /// ([`LaserBuilder::verifier`](crate::laser::LaserBuilder::verifier)), use
494    /// [`quarantine_signed`](Self::quarantine_signed) so the fact also carries a
495    /// signature, since a verifying registry drops an unsigned one.
496    pub async fn quarantine(&self, operator: AgentId, agent: &AgentId) -> Result<(), LaserError> {
497        self.publish_registry_fact(OPERATION_QUARANTINE, operator, agent)
498            .await
499    }
500
501    /// Lift a prior quarantine on `agent` (an `unquarantine` fact), returning it to
502    /// routing. The counterpart to [`quarantine`](Self::quarantine), so a
503    /// quarantine is not a one-way door only retention expiry can undo. Same
504    /// authorization.
505    pub async fn unquarantine(&self, operator: AgentId, agent: &AgentId) -> Result<(), LaserError> {
506        self.publish_registry_fact(OPERATION_UNQUARANTINE, operator, agent)
507            .await
508    }
509
510    async fn publish_registry_fact(
511        &self,
512        operation: &'static str,
513        operator: AgentId,
514        agent: &AgentId,
515    ) -> Result<(), LaserError> {
516        self.agdx(
517            AgentTopic::Registry,
518            operator.wire_id(),
519            ConversationId::mint(),
520        )
521        .status(operation)
522        .body(agent.as_str().as_bytes().to_vec())
523        .send()
524        .await?;
525        Ok(())
526    }
527
528    /// Quarantine `agent` with a signed fact, signed by the operator's `key`, so a
529    /// registry that enrolls the matching verifying key folds it (and rejects an
530    /// unsigned one). The signed counterpart to [`quarantine`](Self::quarantine).
531    #[cfg(feature = "sign")]
532    pub async fn quarantine_signed(
533        &self,
534        operator: AgentId,
535        agent: &AgentId,
536        key: &crate::sign::SigningKey,
537    ) -> Result<(), LaserError> {
538        self.publish_registry_fact_signed(OPERATION_QUARANTINE, operator, agent, key)
539            .await
540    }
541
542    /// Lift a quarantine with a signed fact. The signed counterpart to
543    /// [`unquarantine`](Self::unquarantine).
544    #[cfg(feature = "sign")]
545    pub async fn unquarantine_signed(
546        &self,
547        operator: AgentId,
548        agent: &AgentId,
549        key: &crate::sign::SigningKey,
550    ) -> Result<(), LaserError> {
551        self.publish_registry_fact_signed(OPERATION_UNQUARANTINE, operator, agent, key)
552            .await
553    }
554
555    #[cfg(feature = "sign")]
556    async fn publish_registry_fact_signed(
557        &self,
558        operation: &'static str,
559        operator: AgentId,
560        agent: &AgentId,
561        key: &crate::sign::SigningKey,
562    ) -> Result<(), LaserError> {
563        self.agdx(
564            AgentTopic::Registry,
565            operator.wire_id(),
566            ConversationId::mint(),
567        )
568        .status(operation)
569        .body(agent.as_str().as_bytes().to_vec())
570        .signed_by(key)
571        .send()
572        .await?;
573        Ok(())
574    }
575
576    /// Advertise this connection's live presence (`AGDX_SET_CLIENT_METADATA`): the
577    /// agent id this connection is and the inbox topic it currently consumes, so a
578    /// fused registry can route work to it without any shared topic-name
579    /// convention. Mutable mid-session (re-advertise to move the inbox to a
580    /// per-workflow topic) and garbage-collected for free on disconnect. Use
581    /// [`clear_presence`](Self::clear_presence) to withdraw it.
582    ///
583    /// Served by Iggy, so it returns a transport error against a
584    /// Iggy server that lacks the command.
585    #[cfg(feature = "query")]
586    pub async fn advertise_presence(&self, presence: &AgentPresence) -> Result<(), LaserError> {
587        let requested = AgentId::try_from(presence.agent.as_str())?;
588        self.claim_presence(requested)?;
589        let payload = encode_named(presence)
590            .map_err(|error| LaserError::Codec(format!("encode presence: {error}")))?;
591        self.send_raw_with_response(AGDX_SET_CLIENT_METADATA_CODE, payload)
592            .await?;
593        Ok(())
594    }
595
596    /// Withdraw this connection's advertised presence (an empty
597    /// `AGDX_SET_CLIENT_METADATA` clears it). Disconnecting clears it too, this is
598    /// the explicit early withdrawal.
599    #[cfg(feature = "query")]
600    pub async fn clear_presence(&self) -> Result<(), LaserError> {
601        self.send_raw_with_response(AGDX_SET_CLIENT_METADATA_CODE, Vec::new())
602            .await?;
603        self.release_presence();
604        Ok(())
605    }
606
607    /// Start a filtered, paginated discovery read of live connections and their
608    /// advertised metadata (`AGDX_GET_CLIENTS_METADATA`). Distinct from
609    /// [`agent_registry`](Self::agent_registry): this is live connections from the
610    /// server's table, not folded cards from a topic. Refine with the builder,
611    /// then `.page().await` for one page or `.all().await` to walk every page.
612    /// Served by Iggy, so it returns a transport error against a
613    /// Iggy server that lacks the command.
614    #[cfg(feature = "query")]
615    pub fn client_metadata(&self) -> ClientMetadataRequest<'_> {
616        ClientMetadataRequest {
617            laser: self,
618            query: ClientMetadataQuery {
619                v: CLIENT_METADATA_OP_VERSION,
620                with_metadata_only: false,
621                user_id: None,
622                after_client_id: None,
623                limit: MAX_PAGE_SIZE as u32,
624            },
625        }
626    }
627}
628
629/// One page of connections plus the cursor to fetch the next, returned by
630/// [`ClientMetadataRequest::page`].
631#[cfg(feature = "query")]
632pub struct ClientMetadataPage {
633    pub clients: Vec<ClientMetadata>,
634    /// The `after_client_id` for the next page, or `None` on the last page.
635    pub next_cursor: Option<u32>,
636}
637
638/// Fluent builder for the client-metadata discovery read.
639#[cfg(feature = "query")]
640pub struct ClientMetadataRequest<'a> {
641    laser: &'a Laser,
642    query: ClientMetadataQuery,
643}
644
645#[cfg(feature = "query")]
646impl ClientMetadataRequest<'_> {
647    /// Only return connections that advertised metadata.
648    pub fn with_metadata_only(mut self, value: bool) -> Self {
649        self.query.with_metadata_only = value;
650        self
651    }
652
653    /// Only return connections authenticated as `principal`.
654    pub fn principal(mut self, principal: PrincipalId) -> Self {
655        self.query.user_id = Some(principal.get());
656        self
657    }
658
659    /// Cap the page size (clamped server-side to the page cap).
660    pub fn limit(mut self, limit: u32) -> Self {
661        self.query.limit = limit;
662        self
663    }
664
665    /// Start the page after this `client_id` (the previous page's `next_cursor`).
666    pub fn after(mut self, client_id: u32) -> Self {
667        self.query.after_client_id = Some(client_id);
668        self
669    }
670
671    /// Fetch one page.
672    pub async fn page(&self) -> Result<ClientMetadataPage, LaserError> {
673        let payload = encode_named(&self.query)
674            .map_err(|error| LaserError::Codec(format!("encode request: {error}")))?;
675        let payload = self
676            .laser
677            .send_raw_with_response(AGDX_GET_CLIENTS_METADATA_CODE, payload)
678            .await?;
679        let list: ClientMetadataList = decode_named(&payload)?;
680        Ok(ClientMetadataPage {
681            clients: list.clients,
682            next_cursor: list.next_cursor,
683        })
684    }
685
686    /// Walk every page, following the cursor, and collect all matching
687    /// connections. Use [`page`](Self::page) instead when the set may be large.
688    pub async fn all(self) -> Result<Vec<ClientMetadata>, LaserError> {
689        let mut all = Vec::new();
690        let mut cursor = self.query.after_client_id;
691        loop {
692            let mut request = ClientMetadataRequest {
693                laser: self.laser,
694                query: self.query.clone(),
695            };
696            request.query.after_client_id = cursor;
697            let page = request.page().await?;
698            all.extend(page.clients);
699            match page.next_cursor {
700                Some(next) => cursor = Some(next),
701                None => break,
702            }
703        }
704        Ok(all)
705    }
706}
707
708/// Apply one envelope to the card map: when it is a `status`/`card` record from a
709/// resolvable agent carrying a decodable [`AgentCard`] body, store it as that
710/// agent's latest card. Returns whether a card was applied. Pure (the clock is
711/// passed in), so the fold is unit-testable without a live log.
712fn apply_card(
713    cards: &mut HashMap<AgentId, RegisteredCard>,
714    envelope: &AgentEnvelope,
715    observed_at_micros: u64,
716) -> bool {
717    if envelope.operation.as_deref() != Some(OPERATION_CARD) {
718        return false;
719    }
720    let Ok(agent) = AgentId::try_from(envelope.source.as_str()) else {
721        return false;
722    };
723    let Ok(card) = decode_named::<AgentCard>(&envelope.body) else {
724        return false;
725    };
726    // The card comes off the registry topic, so its caps are enforced on the
727    // read side too. An over-cap card is rejected, not folded in.
728    if card.validate().is_err() {
729        return false;
730    }
731    // A card map keyed by the self-asserted source grows with every distinct id
732    // a writer invents, and a card with no ttl is never swept by freshness. The
733    // cap bounds that, evicting the least recently observed entry.
734    if cards.len() >= MAX_REGISTERED_CARDS && !cards.contains_key(&agent) {
735        let oldest = cards
736            .iter()
737            .min_by_key(|(_, registered)| registered.observed_at_micros)
738            .map(|(id, _)| id.clone());
739        if let Some(oldest) = oldest {
740            cards.remove(&oldest);
741        }
742    }
743    cards.insert(
744        agent.clone(),
745        RegisteredCard {
746            agent,
747            card,
748            observed_at_micros,
749        },
750    );
751    true
752}
753
754/// Apply one envelope to the quarantine set: a `status`/`quarantine` record whose
755/// body is a valid agent id marks that agent quarantined. Returns whether one was
756/// applied. Pure, so the fold is unit-testable without a live log.
757// Cards ordered by agent id in byte order, the order every SDK lists and
758// resolves them in.
759fn by_agent_id<'a>(cards: impl Iterator<Item = &'a RegisteredCard>) -> Vec<&'a RegisteredCard> {
760    let mut cards: Vec<&RegisteredCard> = cards.collect();
761    cards.sort_by(|left, right| left.agent.as_str().cmp(right.agent.as_str()));
762    cards
763}
764
765fn apply_quarantine(quarantined: &mut HashSet<AgentId>, envelope: &AgentEnvelope) -> bool {
766    let Ok(body) = std::str::from_utf8(&envelope.body) else {
767        return false;
768    };
769    let Ok(agent) = AgentId::try_from(body) else {
770        return false;
771    };
772    quarantined.insert(agent)
773}
774
775/// Apply one envelope to the quarantine set as a lift: an `unquarantine` record
776/// whose body is a valid agent id returns that agent to routing. Returns whether a
777/// quarantine was actually lifted. Pure, so the fold is unit-testable without a
778/// live log.
779fn lift_quarantine(quarantined: &mut HashSet<AgentId>, envelope: &AgentEnvelope) -> bool {
780    let Ok(body) = std::str::from_utf8(&envelope.body) else {
781        return false;
782    };
783    let Ok(agent) = AgentId::try_from(body) else {
784        return false;
785    };
786    quarantined.remove(&agent)
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use laser_wire::agent::{CapabilityDescriptor, ConversationId, Health, RecordId};
793    use laser_wire::framing::encode_named;
794
795    fn descriptor(skill_id: &str) -> CapabilityDescriptor {
796        CapabilityDescriptor {
797            skill_id: skill_id.to_owned(),
798            input: None,
799            output: None,
800            cost_class: None,
801            latency_class: None,
802            max_concurrency: None,
803            health: Some(Health::Healthy),
804            load: None,
805        }
806    }
807
808    fn card_envelope(source: &str, skills: &[&str], ttl_micros: Option<u64>) -> AgentEnvelope {
809        let card = AgentCard {
810            name: None,
811            version: None,
812            capabilities: skills.iter().map(|skill| descriptor(skill)).collect(),
813            ttl_micros,
814        };
815        let mut envelope = AgentEnvelope::status(
816            RecordId::from_u128(1),
817            ConversationId::from_u128(2),
818            source.parse().expect("valid agent id"),
819            OPERATION_CARD,
820        );
821        envelope.body = encode_named(&card).expect("card encodes");
822        envelope
823    }
824
825    #[test]
826    fn given_cards_when_folded_then_should_keep_the_latest_per_agent_and_resolve_by_capability() {
827        let mut cards = HashMap::new();
828        let planner: AgentId = "planner".parse().unwrap();
829
830        // Two agents publish cards.
831        assert!(apply_card(
832            &mut cards,
833            &card_envelope("planner", &["plan"], None),
834            100
835        ));
836        assert!(apply_card(
837            &mut cards,
838            &card_envelope("worker", &["diagnose"], Some(50)),
839            100
840        ));
841        assert_eq!(cards.len(), 2);
842
843        // A re-publish replaces the agent's card (latest wins).
844        assert!(apply_card(
845            &mut cards,
846            &card_envelope("planner", &["plan", "summarize"], None),
847            200
848        ));
849        assert_eq!(cards.len(), 2);
850        assert_eq!(cards[&planner].observed_at_micros, 200);
851        assert!(cards[&planner].serves("summarize"));
852
853        // A non-card envelope is skipped.
854        let not_a_card = AgentEnvelope::event(
855            RecordId::from_u128(9),
856            ConversationId::from_u128(9),
857            "x".parse().unwrap(),
858            b"{}".to_vec(),
859        );
860        assert!(!apply_card(&mut cards, &not_a_card, 300));
861
862        // The worker's card (ttl 50, observed at 100) is stale past 150.
863        let worker = cards
864            .values()
865            .find(|c| c.agent.as_str() == "worker")
866            .unwrap();
867        assert!(worker.is_fresh(150));
868        assert!(!worker.is_fresh(151));
869    }
870
871    #[test]
872    fn given_cards_in_any_insertion_order_when_listed_then_should_order_by_agent_id() {
873        let mut cards = HashMap::new();
874        for name in ["zeta", "alpha", "mid", "Beta"] {
875            assert!(apply_card(
876                &mut cards,
877                &card_envelope(name, &["diagnose"], None),
878                100
879            ));
880        }
881        let order: Vec<&str> = by_agent_id(cards.values())
882            .into_iter()
883            .map(|card| card.agent.as_str())
884            .collect();
885        assert_eq!(order, ["Beta", "alpha", "mid", "zeta"]);
886    }
887
888    #[test]
889    fn given_a_quarantine_record_when_folded_then_should_mark_the_named_agent() {
890        let mut quarantined = HashSet::new();
891        let worker: AgentId = "worker".parse().unwrap();
892
893        let mut envelope = AgentEnvelope::status(
894            RecordId::from_u128(1),
895            ConversationId::from_u128(2),
896            "operator".parse().expect("valid agent id"),
897            OPERATION_QUARANTINE,
898        );
899        envelope.body = b"worker".to_vec();
900        assert!(apply_quarantine(&mut quarantined, &envelope));
901        assert!(quarantined.contains(&worker));
902
903        // A second identical record is a no-op (already quarantined).
904        assert!(!apply_quarantine(&mut quarantined, &envelope));
905
906        // A body that is not a valid agent id is skipped, not poisoned in.
907        let mut bad = envelope.clone();
908        bad.body = vec![0xff, 0xfe];
909        assert!(!apply_quarantine(&mut quarantined, &bad));
910
911        // An un-quarantine record lifts it, returning the agent to routing.
912        let mut lift = AgentEnvelope::status(
913            RecordId::from_u128(3),
914            ConversationId::from_u128(4),
915            "operator".parse().expect("valid agent id"),
916            OPERATION_UNQUARANTINE,
917        );
918        lift.body = b"worker".to_vec();
919        assert!(lift_quarantine(&mut quarantined, &lift));
920        assert!(!quarantined.contains(&worker));
921        // Lifting an agent that is not quarantined is a no-op.
922        assert!(!lift_quarantine(&mut quarantined, &lift));
923    }
924
925    #[test]
926    fn given_advertised_health_when_checking_availability_then_should_skip_only_unavailable() {
927        let with_health = |health| RegisteredCard {
928            agent: "a".parse().expect("valid agent id"),
929            card: AgentCard {
930                name: None,
931                version: None,
932                capabilities: vec![CapabilityDescriptor {
933                    health,
934                    ..descriptor("diagnose")
935                }],
936                ttl_micros: None,
937            },
938            observed_at_micros: 0,
939        };
940        assert!(with_health(Some(Health::Healthy)).available_for("diagnose"));
941        assert!(with_health(Some(Health::Degraded)).available_for("diagnose"));
942        assert!(with_health(None).available_for("diagnose"));
943        assert!(
944            !with_health(Some(Health::Unavailable)).available_for("diagnose"),
945            "an agent declaring itself unavailable must not be routed to",
946        );
947        // A skill the card never advertises is not available.
948        assert!(!with_health(Some(Health::Healthy)).available_for("other"));
949    }
950
951    #[cfg(feature = "query")]
952    #[test]
953    fn given_connection_metadata_when_folded_as_presence_then_should_key_by_agent_and_skip_non_presence()
954     {
955        use laser_wire::agent::AgentPresence;
956
957        let mut presence = HashMap::new();
958        let worker: AgentId = "worker".parse().unwrap();
959
960        // A presence body declaring an inbox is folded, keyed by its agent id,
961        // carrying the connection's authenticated user_id.
962        let body =
963            encode_named(&AgentPresence::new("worker".parse().unwrap()).with_inbox("worker.work"))
964                .expect("presence encodes");
965        assert!(apply_presence(&mut presence, &body, Some(7)));
966        assert_eq!(
967            presence[&worker].presence.inbox.as_deref(),
968            Some("worker.work")
969        );
970        assert_eq!(presence[&worker].principal, Some(PrincipalId::new(7)));
971
972        // Re-advertising moves the inbox (last write wins), the per-workflow case.
973        let moved = encode_named(
974            &AgentPresence::new("worker".parse().unwrap()).with_inbox("worker.work.v2"),
975        )
976        .expect("presence encodes");
977        assert!(apply_presence(&mut presence, &moved, Some(7)));
978        assert_eq!(
979            presence[&worker].presence.inbox.as_deref(),
980            Some("worker.work.v2")
981        );
982
983        // An opaque non-presence blob (a regular app's metadata) is skipped, not
984        // poisoned into the map.
985        assert!(!apply_presence(&mut presence, b"not a presence body", None));
986        assert_eq!(presence.len(), 1);
987    }
988}