Skip to main content

contextgraph_host/
error.rs

1//! `HostError` — the one typed error the host runtime raises
2//! (`SPEC.md` §10 "fail loud"). Everything a fan-out or a single
3//! provider exchange can go wrong with is a named variant here; nothing in
4//! the hot path panics. `contextgraph-host` owns its own error type rather than
5//! borrowing `stella`'s so the crate stays industry-facing and dependency-
6//! light (`SPEC.md` §1 — depends only on `contextgraph-types` + transport
7//! crates).
8
9use contextgraph_types::{DataFlow, EgressScope, ErrorCode};
10
11/// Anything the host runtime can surface while talking to a provider.
12#[derive(Debug, thiserror::Error)]
13pub enum HostError {
14    /// The provider speaks an incompatible protocol family
15    /// (`SPEC.md`). Reported the instant the handshake ack
16    /// arrives — never a hang (task deliverable 1).
17    #[error(
18        "protocol version mismatch: host speaks {host}, provider {provider} speaks {provider_version}"
19    )]
20    VersionMismatch {
21        host: String,
22        provider: String,
23        provider_version: String,
24    },
25
26    /// A line/body could not be encoded to or decoded from the wire envelope
27    /// (`SPEC.md` §2). A malformed provider message is a
28    /// clean error, never a host crash (task deliverable 5).
29    #[error("wire encode/decode error: {0}")]
30    Wire(String),
31
32    /// The underlying transport (stdio pipe or HTTP) failed.
33    #[error("transport error talking to provider {id}: {message}")]
34    Transport { id: String, message: String },
35
36    /// The host refused to open a plaintext (`http://`) transport to a
37    /// non-loopback provider (`SPEC.md` §4.2, **C7**): the query payload — and
38    /// any bearer credential — would cross the network in cleartext. Raised
39    /// **before** any bytes are sent, so nothing left the host. The message
40    /// names only the id and host — never a credential (C8).
41    #[error(
42        "refusing an insecure (plaintext http) transport to non-loopback provider {id} at host `{host}`: TLS is required for any non-loopback provider (C7)"
43    )]
44    InsecureTransport { id: String, host: String },
45
46    /// The provider rejected the host's bearer credential (`HTTP 401`). Distinct
47    /// from a bare [`Transport`](Self::Transport) failure so a host can react to
48    /// an auth rejection specifically. The message names only the id and the
49    /// status — never the credential itself (`SPEC.md` §4.2, **C8**).
50    #[error("provider {id} rejected the host credential (HTTP 401 Unauthorized)")]
51    Unauthorized { id: String },
52
53    /// The provider's child process closed its stream mid-exchange — it
54    /// crashed. Isolated to this provider; never poisons a `query_all`
55    /// (task deliverable 5).
56    #[error("provider {id} crashed or closed its stream mid-exchange")]
57    ProviderCrashed { id: String },
58
59    /// The provider took longer than the host's per-provider budget.
60    #[error("provider {id} timed out after {timeout_ms}ms")]
61    Timeout { id: String, timeout_ms: u64 },
62
63    /// The provider reported an error over the wire (an `error` envelope).
64    ///
65    /// `code` carries the structured [`ErrorCode`] the provider sent (#9) so it
66    /// survives the transport boundary instead of collapsing to a bare message;
67    /// a host can then key its reaction ([`ErrorCode::reaction`]) off the code
68    /// rather than sniffing the free-form string. `None` when the provider
69    /// declared no code — read it as [`ErrorCode::Internal`] per SPEC.md.
70    #[error(
71        "provider {id} reported an error{}: {message}",
72        .code.as_ref().map(|c| format!(" ({c})")).unwrap_or_default()
73    )]
74    Provider {
75        id: String,
76        code: Option<ErrorCode>,
77        message: String,
78    },
79
80    /// The provider declares `egress` and has no recorded consent, so the
81    /// host refuses to transmit a query to it (`SPEC.md`
82    /// SPEC.md §4 — a host MUST NOT auto-enable egress providers). The query
83    /// payload never left the host.
84    #[error(
85        "provider {id} declares egress and requires one-time consent naming what leaves before it can be queried"
86    )]
87    ConsentRequired { id: String, data_flow: DataFlow },
88
89    /// The provider declares one or more **off-machine egress scopes** with no
90    /// recorded consent receipt, so the host refuses to transmit a query to it
91    /// (`docs/context-reuse.md` §3 — requirement C6). `scopes` names exactly
92    /// the scopes that would leave unconsented. The query payload never left
93    /// the host.
94    ///
95    // NOTE: the stable `code` string for the typed-error-code work (#9) is not
96    // yet assigned; it slots in alongside `ConsentRequired` when #9 lands.
97    #[error(
98        "provider {id} declares egress scope(s) {scopes:?} with no recorded consent receipt; the query was not transmitted"
99    )]
100    ConsentScopeRequired {
101        id: String,
102        scopes: Vec<EgressScope>,
103    },
104
105    /// A message of the wrong kind arrived where the protocol expected a
106    /// specific envelope (e.g. a `frames` reply to a `query`).
107    #[error("expected a `{expected}` envelope from provider {id}, got `{got}`")]
108    UnexpectedEnvelope {
109        id: String,
110        expected: String,
111        got: String,
112    },
113
114    /// A provider that declared `correlation` answered without echoing the
115    /// request's `id`, or echoed the wrong one (`SPEC.md` §H4).
116    ///
117    /// Fatal to the exchange rather than a warning: once replies cannot be
118    /// matched to requests, a pipelining host could hand one caller's frames to
119    /// another, and silently mixing evidence between tasks is worse than
120    /// failing the query.
121    #[error("provider {id} broke request correlation: expected id `{expected}`, got `{got}`")]
122    CorrelationMismatch {
123        id: String,
124        expected: String,
125        got: String,
126    },
127
128    /// No provider is registered under the given id.
129    #[error("no provider registered with id `{0}`")]
130    UnknownProvider(String),
131
132    /// Spawning the provider child process failed.
133    #[error("failed to spawn provider process: {0}")]
134    Spawn(String),
135}