Skip to main content

lean_ctx_protocol/hive/
bus.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Cross-instance agent bus.
4//!
5//! - `wss://<host>/v1/bus` (bearer token): JSON text frames [`BusFrameV1`],
6//!   tagged by `"t"`. The client sends `hello` first. 1.9 R3: it may send
7//!   `hello` again on the open socket whenever its agents change; a later
8//!   `hello` replaces the announced agents (its `instance_id` must be the
9//!   socket's, otherwise `error` and close), and its `cursors` replay newer
10//!   events only for channels the socket did not follow yet. A frame for an
11//!   agent that no `hello` on this socket announced is refused.
12//! - Fallback `GET /v1/bus/poll?cursor=` → [`BusPollV1`].
13//! - Catch-up `GET /v1/bus/channels/{channel}/events?after_seq=&limit=` (≤500)
14//!   → [`BusEventsV1`].
15//! - Leases `POST /v1/bus/leases` [`BusLeaseRequestV1`] → [`BusLeaseV1`];
16//!   `POST /v1/bus/leases/{lease_id}/renew` [`BusLeaseRenewRequestV1`] →
17//!   [`BusLeaseV1`]; `DELETE /v1/bus/leases/{lease_id}?fencing_token=` → 204.
18//!
19//! Channels are `org:<org_id>/vault:<vault_id>/<name>`. The service
20//! assigns `seq` per channel, strictly increasing, and stamps `server_time`.
21//! A handoff completes only on `handoff.ack`; until its deadline the service
22//! redelivers the offer. Lease expiry is decided by the service clock.
23
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use std::collections::BTreeMap;
27
28use super::error::HiveErrorDetailV1;
29use super::{require_schema, validate_id, validate_text};
30use crate::{UtcTimestamp, ValidationError};
31
32pub const BUS_POLL_SCHEMA: &str = "leanctx.hive.bus-poll/v1";
33pub const BUS_EVENTS_SCHEMA: &str = "leanctx.hive.bus-events/v1";
34pub const BUS_LEASE_REQUEST_SCHEMA: &str = "leanctx.hive.bus-lease-request/v1";
35pub const BUS_LEASE_RENEW_REQUEST_SCHEMA: &str = "leanctx.hive.bus-lease-renew-request/v1";
36pub const BUS_LEASE_SCHEMA: &str = "leanctx.hive.bus-lease/v1";
37
38/// Largest serialized `body` of one message.
39pub const MAX_BUS_BODY_BYTES: usize = 64 * 1024;
40/// Events per catch-up page.
41pub const MAX_BUS_EVENTS_PAGE: usize = 500;
42/// Longest bus lease.
43pub const MAX_BUS_LEASE_MS: u64 = 15 * 60 * 1000;
44
45/// Validate `org:<org>/vault:<vault_id>/<name>`.
46pub fn validate_channel(channel: &str) -> Result<(), ValidationError> {
47    let mut parts = channel.splitn(3, '/');
48    let org = parts.next().and_then(|part| part.strip_prefix("org:"));
49    let vault = parts.next().and_then(|part| part.strip_prefix("vault:"));
50    let name = parts.next();
51    match (org, vault, name) {
52        (Some(org), Some(vault), Some(name)) => {
53            validate_id(org, "channel org")?;
54            super::validate_vault_id(vault)?;
55            validate_id(name, "channel name")
56        }
57        _ => Err(ValidationError::new(
58            "channel must be org:<org>/vault:<vault>/<name>",
59        )),
60    }
61}
62
63/// Agent presence.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum PresenceState {
67    Active,
68    Idle,
69    Closed,
70}
71
72/// Message priority (same values as the local bus).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum BusPriority {
76    Low,
77    Normal,
78    High,
79    Critical,
80}
81
82/// Message privacy (same values as the local bus). `private` bodies are
83/// redacted by the engine before publish and delivered only to `to`.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum BusPrivacy {
87    Public,
88    Team,
89    Private,
90}
91
92/// Control actions an org admin (or the lead agent with `bus.control`) issues.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum ControlAction {
96    /// Refuse mutating tools until `resume`.
97    Hold,
98    /// Refuse the lease or handoff named by `target_ref`.
99    Veto,
100    /// Refuse mutating tools; the agent should end its task.
101    Stop,
102    Resume,
103}
104
105/// An agent announced in `hello`.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct BusAgentV1 {
108    pub agent_id: String,
109    pub role: String,
110    pub vault_id: String,
111}
112
113/// Current lease holder, when a lease was not granted.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct BusLeaseHolderV1 {
116    pub agent_id: String,
117    pub instance_id: String,
118    pub expires_at: UtcTimestamp,
119}
120
121/// One WebSocket frame (also the items of [`BusPollV1`]).
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(tag = "t")]
124pub enum BusFrameV1 {
125    /// client → service, first frame.
126    #[serde(rename = "hello")]
127    Hello {
128        instance_id: String,
129        agents: Vec<BusAgentV1>,
130        /// Last `seq` seen per channel; the service replays newer events.
131        #[serde(default)]
132        cursors: BTreeMap<String, u64>,
133    },
134    /// client → service.
135    #[serde(rename = "presence")]
136    Presence {
137        agent_id: String,
138        state: PresenceState,
139        /// 1.9 (section 5): the agent's self-reported profile.
140        #[serde(default, skip_serializing_if = "Option::is_none")]
141        profile: Option<super::profiles::PresenceProfileV1>,
142    },
143    /// client → service.
144    #[serde(rename = "publish")]
145    Publish {
146        /// Client-chosen ULID; republishing the same id is a no-op.
147        client_msg_id: String,
148        channel: String,
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        to: Option<String>,
151        from: String,
152        kind: String,
153        priority: BusPriority,
154        privacy: BusPrivacy,
155        body: Value,
156    },
157    /// client → service: everything up to `seq` on `channel` is processed.
158    #[serde(rename = "ack")]
159    Ack { channel: String, seq: u64 },
160    /// service → client: a publish was stored.
161    #[serde(rename = "published")]
162    Published {
163        client_msg_id: String,
164        channel: String,
165        seq: u64,
166        server_time: UtcTimestamp,
167    },
168    /// service → client.
169    #[serde(rename = "deliver")]
170    Deliver {
171        seq: u64,
172        channel: String,
173        from: String,
174        #[serde(default, skip_serializing_if = "Option::is_none")]
175        to: Option<String>,
176        kind: String,
177        priority: BusPriority,
178        privacy: BusPrivacy,
179        body: Value,
180        server_time: UtcTimestamp,
181    },
182    /// service → client.
183    #[serde(rename = "presence_update")]
184    PresenceUpdate {
185        agent_id: String,
186        instance_id: String,
187        role: String,
188        vault_id: String,
189        state: PresenceState,
190        server_time: UtcTimestamp,
191        /// 1.9 (section 5): the agent's profile id.
192        #[serde(default, skip_serializing_if = "Option::is_none")]
193        profile_id: Option<String>,
194        /// 1.9 (section 5): what the agent announced.
195        #[serde(default, skip_serializing_if = "Option::is_none")]
196        profile: Option<super::profiles::PresenceProfileV1>,
197    },
198    /// service → client.
199    #[serde(rename = "control")]
200    Control {
201        control_id: String,
202        /// Absent: every agent of the instance.
203        #[serde(default, skip_serializing_if = "Option::is_none")]
204        target_agent: Option<String>,
205        /// Lease or handoff id for `veto`.
206        #[serde(default, skip_serializing_if = "Option::is_none")]
207        target_ref: Option<String>,
208        action: ControlAction,
209        reason: String,
210        issued_by: String,
211        seq: u64,
212        server_time: UtcTimestamp,
213    },
214    /// either direction.
215    #[serde(rename = "handoff.offer")]
216    HandoffOffer {
217        handoff_id: String,
218        channel: String,
219        from: String,
220        to: String,
221        /// Reference to a context bundle the receiver fetches; never the content.
222        bundle_ref: String,
223        deadline: UtcTimestamp,
224    },
225    /// receiver → service → offerer.
226    #[serde(rename = "handoff.ack")]
227    HandoffAck { handoff_id: String, by: String },
228    /// receiver → service → offerer.
229    #[serde(rename = "handoff.reject")]
230    HandoffReject {
231        handoff_id: String,
232        by: String,
233        reason: String,
234    },
235    /// 1.9 (section 2), client → service: an agent message.
236    #[serde(rename = "message")]
237    Message(super::messages::AgentMessageV1),
238    /// 1.9, service → sender: the message was stored (its `gid`).
239    #[serde(rename = "message.stored")]
240    MessageStored(super::messages::MessageStoredV1),
241    /// 1.9, service → client.
242    #[serde(rename = "message.deliver")]
243    MessageDeliver(super::messages::MessageDeliveryV1),
244    /// 1.9, client → service: delivered or read.
245    #[serde(rename = "message.receipt")]
246    MessageReceipt(super::messages::MessageReceiptV1),
247    /// 1.9, service → sender.
248    #[serde(rename = "message.status")]
249    MessageStatus(super::messages::MessageStatusV1),
250    /// 1.9, service → client: a task changed (section 1).
251    #[serde(rename = "task.event")]
252    TaskEvent(super::messages::TaskEventV1),
253    /// 1.9, service → client: a knowledge revision was released,
254    /// quarantined or rolled back (section 3).
255    #[serde(rename = "knowledge.event")]
256    KnowledgeEvent(super::knowledge::KnowledgeEventV1),
257    #[serde(rename = "ping")]
258    Ping { nonce: u64 },
259    #[serde(rename = "pong")]
260    Pong { nonce: u64 },
261    /// service → client: a frame was refused; the socket stays open.
262    #[serde(rename = "error")]
263    Error {
264        #[serde(default, skip_serializing_if = "Option::is_none")]
265        client_msg_id: Option<String>,
266        error: HiveErrorDetailV1,
267    },
268    /// A frame type this engine does not know; ignored.
269    #[serde(other)]
270    Unknown,
271}
272
273impl BusFrameV1 {
274    /// Checks the service applies to client frames (and the engine before sending).
275    pub fn validate(&self) -> Result<(), ValidationError> {
276        match self {
277            Self::Hello {
278                instance_id,
279                agents,
280                cursors,
281            } => {
282                validate_id(instance_id, "instance_id")?;
283                if agents.len() > 64 || cursors.len() > 256 {
284                    return Err(ValidationError::new("hello lists are too long"));
285                }
286                for agent in agents {
287                    validate_id(&agent.agent_id, "agent_id")?;
288                    validate_text(&agent.role, 64, "role", false)?;
289                    super::validate_vault_id(&agent.vault_id)?;
290                }
291                cursors
292                    .keys()
293                    .try_for_each(|channel| validate_channel(channel))
294            }
295            Self::Presence {
296                agent_id, profile, ..
297            } => {
298                validate_id(agent_id, "agent_id")?;
299                profile
300                    .as_ref()
301                    .map_or(Ok(()), super::profiles::PresenceProfileV1::validate)
302            }
303            Self::Publish {
304                client_msg_id,
305                channel,
306                to,
307                from,
308                kind,
309                body,
310                ..
311            } => {
312                validate_id(client_msg_id, "client_msg_id")?;
313                validate_channel(channel)?;
314                validate_id(from, "from")?;
315                if let Some(to) = to {
316                    validate_id(to, "to")?;
317                }
318                validate_id(kind, "kind")?;
319                validate_body(body)
320            }
321            Self::Ack { channel, .. } => validate_channel(channel),
322            Self::HandoffOffer {
323                handoff_id,
324                channel,
325                from,
326                to,
327                bundle_ref,
328                ..
329            } => {
330                validate_id(handoff_id, "handoff_id")?;
331                validate_channel(channel)?;
332                validate_id(from, "from")?;
333                validate_id(to, "to")?;
334                validate_text(bundle_ref, 512, "bundle_ref", false)
335            }
336            Self::HandoffAck { handoff_id, by } => {
337                validate_id(handoff_id, "handoff_id")?;
338                validate_id(by, "by")
339            }
340            Self::HandoffReject {
341                handoff_id,
342                by,
343                reason,
344            } => {
345                validate_id(handoff_id, "handoff_id")?;
346                validate_id(by, "by")?;
347                validate_text(reason, 500, "reason", false)
348            }
349            Self::Message(message) => message.validate(),
350            Self::MessageReceipt(receipt) => receipt.validate(),
351            Self::Ping { .. } | Self::Pong { .. } => Ok(()),
352            Self::Published { .. }
353            | Self::Deliver { .. }
354            | Self::PresenceUpdate { .. }
355            | Self::Control { .. }
356            | Self::MessageStored(_)
357            | Self::MessageDeliver(_)
358            | Self::MessageStatus(_)
359            | Self::TaskEvent(_)
360            | Self::KnowledgeEvent(_)
361            | Self::Error { .. }
362            | Self::Unknown => Err(ValidationError::new("not a client frame")),
363        }
364    }
365}
366
367fn validate_body(body: &Value) -> Result<(), ValidationError> {
368    let size = serde_json::to_vec(body)
369        .map_err(|error| ValidationError::new(format!("body: {error}")))?
370        .len();
371    if size > MAX_BUS_BODY_BYTES {
372        return Err(ValidationError::new("bus body exceeds 64 KiB"));
373    }
374    Ok(())
375}
376
377/// Response of `GET /v1/bus/poll`.
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379pub struct BusPollV1 {
380    pub schema: String,
381    pub frames: Vec<BusFrameV1>,
382    /// Opaque; pass back as `cursor`.
383    pub cursor: String,
384}
385
386/// Response of `GET /v1/bus/channels/{channel}/events`; `events` are `deliver` frames.
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388pub struct BusEventsV1 {
389    pub schema: String,
390    pub channel: String,
391    pub events: Vec<BusFrameV1>,
392    /// Pass as `after_seq` for the next page; absent when caught up.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub next_after_seq: Option<u64>,
395}
396
397impl BusEventsV1 {
398    pub fn validate(&self) -> Result<(), ValidationError> {
399        require_schema(&self.schema, BUS_EVENTS_SCHEMA)?;
400        validate_channel(&self.channel)?;
401        if self.events.len() > MAX_BUS_EVENTS_PAGE {
402            return Err(ValidationError::new("event page is too long"));
403        }
404        let mut previous = None;
405        for event in &self.events {
406            let BusFrameV1::Deliver { seq, channel, .. } = event else {
407                return Err(ValidationError::new("catch-up events are deliver frames"));
408            };
409            if channel != &self.channel || previous.is_some_and(|previous| *seq <= previous) {
410                return Err(ValidationError::new(
411                    "events must belong to the channel in ascending seq",
412                ));
413            }
414            previous = Some(*seq);
415        }
416        Ok(())
417    }
418}
419
420/// `POST /v1/bus/leases`.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct BusLeaseRequestV1 {
424    pub schema: String,
425    /// e.g. `file`, `task`, `branch`.
426    pub resource_kind: String,
427    /// Lowercase hex SHA-256 of the repo-relative resource; never a path.
428    pub resource_ref: String,
429    pub vault_id: String,
430    pub owner_agent_id: String,
431    pub duration_ms: u64,
432}
433
434impl BusLeaseRequestV1 {
435    pub fn validate(&self) -> Result<(), ValidationError> {
436        require_schema(&self.schema, BUS_LEASE_REQUEST_SCHEMA)?;
437        validate_id(&self.resource_kind, "resource_kind")?;
438        super::validate_hex64(&self.resource_ref, "resource_ref")?;
439        super::validate_vault_id(&self.vault_id)?;
440        validate_id(&self.owner_agent_id, "owner_agent_id")?;
441        validate_duration(self.duration_ms)
442    }
443}
444
445fn validate_duration(duration_ms: u64) -> Result<(), ValidationError> {
446    if (1..=MAX_BUS_LEASE_MS).contains(&duration_ms) {
447        Ok(())
448    } else {
449        Err(ValidationError::new("duration_ms must be 1-900000"))
450    }
451}
452
453/// `POST /v1/bus/leases/{lease_id}/renew`.
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(deny_unknown_fields)]
456pub struct BusLeaseRenewRequestV1 {
457    pub schema: String,
458    pub fencing_token: u64,
459    pub duration_ms: u64,
460}
461
462impl BusLeaseRenewRequestV1 {
463    pub fn validate(&self) -> Result<(), ValidationError> {
464        require_schema(&self.schema, BUS_LEASE_RENEW_REQUEST_SCHEMA)?;
465        validate_duration(self.duration_ms)
466    }
467}
468
469/// Lease decision. Granted: `lease_id`, `fencing_token` (strictly increasing
470/// per resource) and `expires_at`. Refused: `holder`. 1.9 erratum: a renewal
471/// keeps `lease_id` and `fencing_token` and only moves `expires_at`; a new
472/// token comes only with a new grant.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct BusLeaseV1 {
475    pub schema: String,
476    pub granted: bool,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub lease_id: Option<String>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub fencing_token: Option<u64>,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub expires_at: Option<UtcTimestamp>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub holder: Option<BusLeaseHolderV1>,
485    pub server_time: UtcTimestamp,
486}
487
488impl BusLeaseV1 {
489    pub fn validate(&self) -> Result<(), ValidationError> {
490        require_schema(&self.schema, BUS_LEASE_SCHEMA)?;
491        let granted_fields =
492            self.lease_id.is_some() && self.fencing_token.is_some() && self.expires_at.is_some();
493        if self.granted != granted_fields || (self.granted && self.holder.is_some()) {
494            return Err(ValidationError::new(
495                "a granted lease has id, token and expiry; a refused one has none",
496            ));
497        }
498        Ok(())
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn channels_have_three_parts() {
508        assert!(
509            validate_channel("org:org_1/vault:3f6c1e9a0b7d4c2e8f5a1b6c9d0e7f42/general").is_ok()
510        );
511        assert!(validate_channel("org:org_1/vault:prj_1/general").is_err());
512        assert!(validate_channel("org:org_1/general").is_err());
513        assert!(validate_channel("vault:prj_1/org:org_1/general").is_err());
514    }
515
516    #[test]
517    fn unknown_frames_are_tolerated() {
518        let frame: BusFrameV1 = serde_json::from_str(r#"{"t":"future","x":1}"#).unwrap();
519        assert_eq!(frame, BusFrameV1::Unknown);
520    }
521}