agentplane 0.30.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
//! Calling tools on other people's servers.
//!
//! # The security decision this module exists to make
//!
//! MCP servers advertise their tools with *annotations*: `readOnlyHint`,
//! `destructiveHint`, `idempotentHint`. They read like a safety contract. The
//! specification is explicit that they are not:
//!
//! > Clients **MUST** consider tool annotations to be untrusted unless they come
//! > from trusted servers.
//!
//! That warning lands harder here than in most runtimes, because of how the
//! declarations compose. `readOnlyHint: true` would mean [`Effect::mutates`] is
//! false; a non-mutating effect defaults to [`Recovery::Retry`]; and a retried
//! call is a second real call. So a server that marks its own money-moving tool
//! read-only could arrange for that tool to be **retried after a timeout** —
//! choosing, from the far side of the trust boundary, the one condition under
//! which this runtime performs something twice.
//!
//! So safety is not taken from the wire. It comes from a [`ToolCatalog`] the
//! operator writes, and:
//!
//! * **A tool absent from the catalogue cannot be called at all.** Fail closed:
//!   an unknown tool is one nobody has reasoned about, and discovering tools at
//!   runtime is exactly how an agent acquires authority nobody granted.
//! * **Advertised hints are recorded and compared, never obeyed.** A server
//!   claiming more safety than the operator granted is not a nuisance to
//!   normalise away — it is a signal, and it is reported.
//!
//! # What is *not* second-guessed
//!
//! The tool's output. It arrives as [`Tainted`](crate::core::Tainted) and
//! untrusted, like every other effect result, because it is the outside world's
//! data. Nothing in the catalogue can change that: the catalogue governs
//! authority, not provenance.

#[cfg(feature = "mcp")]
mod mcp;
// Not gated on a transport: a typed tool is a tool this process implements, and
// needs no wire at all.
mod typed;

#[cfg(feature = "mcp")]
pub use mcp::{
    McpAccess, McpClient, McpDataSafety, McpPrompt, McpResource, McpTask, McpTaskCancel,
    McpTaskPoll, McpTaskSnapshot, McpTaskState, McpTaskUpdate,
};
/// The MCP SDK this host is built on, re-exported because it is public API:
/// [`McpClient::new`] takes an rmcp `RunningService`, so a caller wiring a
/// transport must name rmcp types at exactly the version this crate links —
/// a direct dependency would have to be re-unified by hand on every upgrade.
#[cfg(feature = "mcp")]
pub use rmcp;
pub use typed::{Tool, ToolBox, ToolFailure};

use std::collections::BTreeMap;
use std::fmt::Debug;
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::core::{
    Disposition, Effect, EffectDescriptor, EffectError, ProtectedField, Recovery, RetryPolicy,
    Sensitivity, Trust,
};

/// Which tool, on which server.
///
/// The server is part of the identity because two servers may both offer
/// `transfer`, and they are not the same tool — the catalogue must be able to
/// permit one and refuse the other.
///
/// The server name is the **operator's local name for a provider**, not
/// anything the far side chose, and it is what [`ToolRouter`] dispatches on.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ToolId {
    pub server: String,
    pub tool: String,
}

/// The scheme a manifest grant is written in.
///
/// Deliberately transport-neutral. Naming the transport — `mcp://server/tool`
/// for a tool compiled into this binary, which never touches MCP — would assert
/// a supply-chain fact the document cannot know, to readers who have no way to
/// check it. A manifest is a **review artifact** and an Agent Card republishes
/// its tool references, so a false statement in it is the defect this crate
/// refuses everywhere else.
///
/// A reference names *which tool*. Which transport reaches its server is a
/// **deployment** decision, made by [`ToolRouter`] — and keeping it there is
/// also what lets one manifest run against an in-process double in a test and a
/// real MCP server in production, which a transport-bearing reference forbids.
pub const TOOL_SCHEME: &str = "tool://";

/// The reserved server that names **agents on this plane** rather than a
/// transport.
///
/// A grant spelled `tool://agent/<capability>` offers another agent's
/// capability to a tool-calling model. Dispatch is `StepCtx::commission`, not
/// a wire: the consultation is a journaled delegation effect, so it replays,
/// the label travels, the sub-run's spend bills the run that asked, and the
/// specialist's own manifest still governs everything it does. The server
/// component is reserved — wiring a remote transport or a typed tool under
/// this name is refused at build, because a name that could mean either "an
/// agent here" or "somebody's server" would let a deployment change which one
/// answers without changing any reviewed document.
pub const AGENT_SERVER: &str = "agent";

impl ToolId {
    pub fn new(server: impl Into<String>, tool: impl Into<String>) -> Self {
        Self {
            server: server.into(),
            tool: tool.into(),
        }
    }

    /// How a **manifest** names this tool: `tool://server/name`.
    ///
    /// The one spelling a grant is matched against. Built here rather than at
    /// each call site because it was built at each call site, in two formats,
    /// and a tool that resolved in the catalogue then failed the manifest gate
    /// was the result.
    #[must_use]
    pub fn reference(&self) -> String {
        format!("{TOOL_SCHEME}{}/{}", self.server, self.tool)
    }

    /// The inverse of [`reference`](Self::reference).
    ///
    /// `None` for anything that is not `tool://server/name`, and for
    /// components no wire name could carry — see [`wire_name`](Self::wire_name)
    /// for the charset. Refusing rather than guessing: a reference this cannot
    /// parse is one no router can dial, and inventing an id from it would
    /// grant a tool nobody wrote down.
    #[must_use]
    pub fn parse(reference: &str) -> Option<Self> {
        let rest = reference.strip_prefix(TOOL_SCHEME)?;
        let (server, tool) = rest.split_once('/')?;
        (valid_component(server) && valid_component(tool) && !tool.contains('/'))
            .then(|| Self::new(server, tool))
    }

    /// How a **model** names this tool: `server__tool`, dots rendered as `-`.
    ///
    /// Neither `tool://server/tool` nor `server/tool` is a legal function name
    /// — providers restrict them to letters, digits, underscore and hyphen
    /// (Gemini also permits dots, but a name must be legal everywhere) — so a
    /// `:` or `/` is rejected before the model ever sees the tool. Two rules
    /// make the rendering readable *and* injective:
    ///
    /// * **`.` becomes `-`.** Capabilities are conventionally dotted
    ///   (`blog.research`), and `-` is legal in every shipped provider's
    ///   charset — so `tool://agent/blog.research` reads as
    ///   `agent__blog-research` rather than an escape soup.
    /// * **The separator is `__`, and it cannot occur elsewhere.** Components
    ///   are refused at declaration if they contain `-` (which would collide
    ///   with a rendered dot), contain `__`, or start or end with `_` (either
    ///   of which would let ordinary underscores run into the separator). The
    ///   one `__` in a wire name is therefore the boundary, and the mapping
    ///   back is exact.
    ///
    /// An earlier scheme escaped `_`→`_u` and `.`→`_d`, which was injective
    /// and unreadable — `agent__blog_dresearch` — and its readability cost was
    /// paid at exactly the wrong moment: by someone diffing a model's chosen
    /// tool against an operator's grant. Refusing a few pathological names at
    /// declaration ([`ToolCatalog::allow`] panics; [`ToolId::parse`] returns
    /// `None`) is the better trade.
    ///
    /// This is the name [`ToolCatalog::resolve`] matches, because it is the
    /// only one a model can actually emit.
    #[must_use]
    pub fn wire_name(&self) -> String {
        format!(
            "{}__{}",
            wire_component(&self.server),
            wire_component(&self.tool)
        )
    }
}

/// Render one component for the wire: dots become hyphens.
///
/// Injective because [`valid_component`] refuses a literal `-` on the way in —
/// every hyphen a model sees denotes a dot, and every other byte is itself.
fn wire_component(value: &str) -> String {
    value.replace('.', "-")
}

/// Whether a server or tool name may appear in a wire name.
///
/// Letters, digits, `_` and `.` only; no `__`; no leading or trailing `_`; no
/// `-`. Each refusal protects the wire rendering's injectivity or the
/// providers' charsets — see [`ToolId::wire_name`] — and each is enforced
/// where a tool is *declared*, never against a name a model emitted: a model's
/// near-miss is a failed resolve, not a panic.
fn valid_component(value: &str) -> bool {
    !value.is_empty()
        && value
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
        && !value.contains("__")
        && !value.starts_with('_')
        && !value.ends_with('_')
}

impl std::fmt::Display for ToolId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.server, self.tool)
    }
}

/// What the *operator* says about a tool.
///
/// Every field here decides something the runtime will do when a call goes
/// wrong, which is why none of them may come from the server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolSafety {
    /// Whether calling this changes the world.
    ///
    /// Defaults to `true`, and the default is the whole point: an operator who
    /// has not thought about a tool gets the answer that makes the runtime
    /// cautious rather than the one that makes it fast.
    pub mutates: bool,
    /// What to do when an attempt's outcome is unknown.
    pub recovery: Recovery,
    /// The highest sensitivity this tool may be *sent*.
    pub max_sensitivity: Sensitivity,
    /// The lowest sensitivity its results carry.
    pub output_sensitivity: Sensitivity,
    /// How many attempts, and how spaced.
    pub retry: RetryPolicy,
    /// High-risk JSON arguments whose source constraints are stricter than
    /// ordinary content fields.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub protected_fields: Vec<ProtectedField>,
}

impl Default for ToolSafety {
    fn default() -> Self {
        Self {
            mutates: true,
            recovery: Recovery::RequiresOperator,
            max_sensitivity: Sensitivity::Public,
            output_sensitivity: Sensitivity::Public,
            retry: RetryPolicy::never(),
            protected_fields: Vec::new(),
        }
    }
}

/// Protected fields in a canonical order.
///
/// Order carries no meaning here — the set is what matters — so anything that
/// hashes or compares these must not be able to see it.
pub(crate) fn sorted_fields(fields: &[ProtectedField]) -> Vec<ProtectedField> {
    let mut out = fields.to_vec();
    out.sort_by(|left, right| left.path().cmp(right.path()));
    out
}

impl ToolSafety {
    /// The safety a manifest grant declares.
    ///
    /// The fields a manifest does not carry — retry, recovery, output
    /// sensitivity — take their cautious defaults, which is where a
    /// hand-written safety starts too. One derivation, shared by the derived
    /// catalogue and by a peer call held to its grant, so the two cannot read
    /// one declaration differently.
    #[cfg(feature = "manifest")]
    #[must_use]
    pub fn from_grant(grant: &crate::manifest::ToolGrant) -> Self {
        Self {
            mutates: grant.mutates,
            protected_fields: grant.protected_fields.clone(),
            max_sensitivity: grant
                .max_sensitivity
                .unwrap_or(Self::default().max_sensitivity),
            ..Self::default()
        }
    }

    /// A tool that only reads.
    ///
    /// Named for what the operator is asserting, not for what a server claimed.
    #[must_use]
    pub fn read_only() -> Self {
        Self {
            mutates: false,
            recovery: Recovery::Retry,
            ..Self::default()
        }
    }

    #[must_use]
    pub fn recovery(mut self, r: Recovery) -> Self {
        self.recovery = r;
        self
    }

    #[must_use]
    pub const fn max_sensitivity(mut self, s: Sensitivity) -> Self {
        self.max_sensitivity = s;
        self
    }

    #[must_use]
    pub const fn output_sensitivity(mut self, s: Sensitivity) -> Self {
        self.output_sensitivity = s;
        self
    }

    #[must_use]
    pub fn retry(mut self, r: RetryPolicy) -> Self {
        self.retry = r;
        self
    }

    /// Add a field-level information-flow rule.
    #[must_use]
    pub fn protect(mut self, field: ProtectedField) -> Self {
        assert!(
            !self
                .protected_fields
                .iter()
                .any(|existing| existing.path() == field.path()),
            "a protected tool argument may be declared only once"
        );
        self.protected_fields.push(field);
        self.protected_fields
            .sort_by(|left, right| left.path().cmp(right.path()));
        self
    }
}

/// What a server said about its own tool.
///
/// Recorded so an operator can see it and so disagreements are visible. Never
/// consulted when deciding what the runtime will do.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Advertised {
    pub read_only: Option<bool>,
    pub destructive: Option<bool>,
    pub idempotent: Option<bool>,
}

impl Advertised {
    /// Whether the server claims more safety than the operator granted.
    ///
    /// Not an error — a server is entitled to describe itself, and an operator
    /// is entitled to disagree. It is reported because the interesting case is
    /// a server that *starts* claiming to be read-only after an update, which
    /// is what a compromised or swapped-out server looks like from here.
    #[must_use]
    pub fn overclaims(&self, safety: &ToolSafety) -> bool {
        // Every overclaim is a safety promise about a tool the operator declared
        // *mutating*; a read-only grant has nothing to overclaim against.
        if !safety.mutates {
            return false;
        }
        // Read-only or "not destructive" on a mutating grant are the same signal
        // — a server claiming more safety than it was given, which is what a
        // swapped-out server looks like from here. `destructive` was recorded and
        // never read, so the claim that most invites "this is safe to repeat"
        // went unchecked.
        self.read_only == Some(true)
            || self.destructive == Some(false)
            // Idempotent, but only where repeating is genuinely unsafe.
            || (self.idempotent == Some(true)
                && matches!(safety.recovery, Recovery::RequiresOperator))
    }
}

/// Why a tool call could not be made, or did not work.
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
    /// The call never left. Safe to repeat.
    #[error("could not reach '{tool}': {detail}")]
    Unreachable { tool: ToolId, detail: String },

    /// The server received the request and declined it without running the tool
    /// — unknown method, invalid arguments. The request is intact.
    #[error("'{tool}' refused the request: {detail}")]
    Refused { tool: ToolId, detail: String },

    /// It was sent and the outcome is unknown.
    ///
    /// The dangerous one, and the reason [`ToolSafety::recovery`] exists: a
    /// timeout is not evidence that nothing happened.
    #[error("'{tool}' did not answer in time: {detail}")]
    TimedOut { tool: ToolId, detail: String },

    /// The server ran the tool and the tool reported failure.
    ///
    /// Distinct from the two above because the call *landed*: repeating it would
    /// be a second real invocation.
    #[error("'{tool}' reported an error: {detail}")]
    ToolFailed { tool: ToolId, detail: String },

    /// The server answered with something that is not a tool result.
    #[error("'{tool}' returned a malformed response: {detail}")]
    Malformed { tool: ToolId, detail: String },
}

impl ToolError {
    /// What this failure says about whether the call reached the world.
    #[must_use]
    pub const fn disposition(&self) -> Disposition {
        match self {
            Self::Unreachable { .. } | Self::Refused { .. } => Disposition::DidNotHappen,
            Self::TimedOut { .. } => Disposition::InDoubt,
            // The tool ran. Both of these mean the far side did work.
            Self::ToolFailed { .. } | Self::Malformed { .. } => Disposition::Landed,
        }
    }
}

/// Where a [`ToolClient`] sends a call.
///
/// # Why a transport has to say this
///
/// [`Egress`](crate::core::Egress) does set membership on a **host**, and
/// every other outbound path hands it one — a model driver parses its base
/// URL, a peer client its card URL. A tool grant is `tool://server/name`,
/// which names a catalogue entry and not a destination, so a client cannot be
/// asked to parse a URL it may not have: it declares the answer, and the
/// declaration is what the allowlist judges.
///
/// # What each answer means, and what it does not claim
///
/// [`Local`](Self::Local) says *this client opens no network connection from
/// this plane*: tools compiled into the binary, an MCP server run as a child
/// process over stdio, a test double. It is **not** a claim that the far side
/// reaches nothing — a child process can open its own socket, and that is the
/// same residual as a compromised allowlisted endpoint. What it claims is that
/// no host of this plane's choosing is being contacted, which is the question
/// an allowlist can answer.
///
/// [`Remote`](Self::Remote) names the host. Deny-by-default applies to it once
/// an [`Egress`](crate::core::Egress) is wired.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Destination {
    /// Opens no network connection from this plane.
    Local,
    /// Connects to this host.
    Remote(String),
}

impl Destination {
    /// Name the host this client connects to.
    pub fn remote(host: impl Into<String>) -> Self {
        Self::Remote(host.into())
    }

    /// The host an allowlist judges, or `None` for a local transport.
    #[must_use]
    pub fn host(&self) -> Option<&str> {
        match self {
            Self::Local => None,
            Self::Remote(host) => Some(host),
        }
    }
}

/// Transports a tool call. Implemented by the MCP adapter, or by anything else.
#[async_trait]
pub trait ToolClient: Send + Sync + Debug {
    /// Invoke a tool and return its result.
    ///
    /// # Errors
    ///
    /// A [`ToolError`] whose variant states what is known about whether the call
    /// reached the far side. Getting that wrong is how a payment happens twice,
    /// so an implementation that cannot tell must say [`ToolError::TimedOut`].
    /// `provenance` is what the callee may check: which run, which effect,
    /// which agent, signed for exactly this call. A transport that has nowhere
    /// to put it ignores it — that is a weaker deployment, not a broken one —
    /// but it must never *invent* one, because a fabricated block is precisely
    /// the compromised-intermediary case the signature exists to detect.
    async fn call(
        &self,
        tool: &ToolId,
        arguments: &Value,
        provenance: Option<&crate::core::Provenance>,
    ) -> Result<Value, ToolError>;

    /// Where this client sends *this* call, for the plane's egress allowlist.
    ///
    /// Per tool rather than per client, because [`ToolRouter`] is itself a
    /// `ToolClient` fanning out to one transport per server: a single answer
    /// for the whole router would have to be the union of its routes, and a
    /// union is exactly the wildcard `Egress` refuses to have.
    ///
    /// **No default, deliberately**, for the reason
    /// [`JournalStore::is_shared`](crate::journal::JournalStore::is_shared)
    /// has none: a default of [`Destination::Local`] would let a remote
    /// transport answer *reaches nobody* by saying nothing, and the runtime
    /// uses this answer to refuse a destination the deployment never granted —
    /// and a control that fails open when an implementer forgets is not a
    /// control.
    ///
    /// Answered cheaply — it is read before every dispatch, so a client that
    /// resolved DNS here would put a lookup in the hot path.
    fn destination(&self, tool: &ToolId) -> Destination;
}

/// The tools this plane may call, and what the operator says about each.
#[derive(Debug, Default, Clone)]
pub struct ToolCatalog {
    entries: BTreeMap<ToolId, (ToolSafety, Advertised)>,
    /// What a model is shown. Separate from safety because presentation is not
    /// authority, and absent for catalogues used only by hand-written skills.
    #[cfg(feature = "manifest")]
    declarations: BTreeMap<ToolId, (String, Value)>,
}

impl ToolCatalog {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Every tool a manifest declares, with the safety it declared.
    ///
    /// # Why this exists
    ///
    /// A manifest and a catalogue are two parties speaking: the agent's author
    /// says what it needs, the operator says what it may have. That separation
    /// is real in a deployment where those are different people — and pure tax
    /// when they are the same one, which is most of the time. Stating `mutates`,
    /// `max_sensitivity` and the protected fields **twice** is not two
    /// decisions, it is one decision and a chance to disagree about it.
    ///
    /// So the common case is one declaration. An operator who wants to say
    /// something different still can: `allow` after this replaces an entry, and
    /// the manifest's own grant is re-checked at dispatch regardless — a
    /// catalogue cannot widen what a declaration never asked for.
    ///
    /// The fields a manifest does not carry — retry, recovery, output
    /// sensitivity — take their cautious defaults, which is the same posture a
    /// hand-written `ToolSafety::default()` starts from.
    #[cfg(feature = "manifest")]
    #[must_use]
    pub fn from_manifest(manifest: &crate::manifest::Manifest) -> Self {
        let mut catalog = Self::new();
        for grant in &manifest.spec.tools {
            let Some(id) = ToolId::parse(&grant.reference) else {
                // A reference the transport cannot name is refused by manifest
                // validation, so reaching here means the two disagree about
                // what a reference is — and inventing an id would grant a tool
                // nobody wrote down.
                continue;
            };
            catalog = catalog.allow(id.clone(), ToolSafety::from_grant(grant));
            if grant.description.is_some() || grant.arguments.is_some() {
                catalog = catalog.declare(
                    id,
                    grant.description.clone().unwrap_or_default(),
                    grant
                        .arguments
                        .clone()
                        .unwrap_or_else(|| serde_json::json!({ "type": "object" })),
                );
            }
        }
        catalog
    }

    /// Permit a tool, with the operator's declaration of what it does.
    ///
    /// # Panics
    ///
    /// If either component of the id could not appear in a wire name — a
    /// literal `-`, a `__`, a leading or trailing `_`, or a byte outside
    /// letters, digits, `_` and `.` — because a granted tool the model cannot
    /// be offered under an unambiguous name is a wiring mistake, and this is
    /// the one place every declaration passes. See [`ToolId::wire_name`].
    #[must_use]
    pub fn allow(mut self, id: ToolId, safety: ToolSafety) -> Self {
        assert!(
            valid_component(&id.server) && valid_component(&id.tool),
            "tool id '{id}' cannot be rendered as a wire name: server and tool may \
             hold letters, digits, `_` and `.`, with no `-`, no `__`, and no leading \
             or trailing `_` — a name outside that set would collide with, or split \
             on, the `__` separator and the `.`→`-` rendering"
        );
        self.entries.insert(id, (safety, Advertised::default()));
        self
    }

    /// Attach the description and argument schema a model is shown.
    ///
    /// Crate-private because callers should either derive this from a
    /// [`ToolBox`] or keep it in the digest-covered manifest. A third public
    /// declaration path would recreate the drift this method removes.
    #[cfg(feature = "manifest")]
    pub(crate) fn declare(mut self, id: ToolId, description: String, arguments: Value) -> Self {
        self.declarations.insert(id, (description, arguments));
        self
    }

    /// Model-facing declaration, when this catalogue carries one.
    #[cfg(feature = "manifest")]
    pub(crate) fn declaration(&self, id: &ToolId) -> Option<(&str, &Value)> {
        self.declarations
            .get(id)
            .map(|(description, arguments)| (description.as_str(), arguments))
    }

    /// Every entry, so several agents' catalogues can be combined.
    ///
    /// Owned rather than borrowed because a merge builds a new catalogue: two
    /// agents on one plane may each declare tools, and the plane needs both.
    #[must_use]
    pub fn entries(self) -> Vec<(ToolId, ToolSafety)> {
        self.entries
            .into_iter()
            .map(|(id, (safety, _))| (id, safety))
            .collect()
    }

    /// Resolve a name a **model** chose to a tool the operator granted.
    ///
    /// This is the bridge between a completion and a dispatch, and it is where
    /// most of the risk in tool-calling lives. A model emits a flat string it
    /// generated; everything downstream treats the result as authority. So the
    /// match is exact and total: the name must equal a granted tool's
    /// `server/tool` rendering, byte for byte.
    ///
    /// **Nothing is resolved approximately.** No case folding, no trimming, no
    /// nearest neighbour, no prefix. A model that writes `ledger/Transfer` or
    /// `ledger/transfer ` gets a refusal, not the tool it nearly named — because
    /// the whole point of a catalogue is that authority comes from the
    /// operator's list rather than from a string a model produced, and a
    /// resolver that helpfully corrects a near miss has handed the model the
    /// power to reach a tool by describing it.
    ///
    /// The name compared is [`ToolId::wire_name`] — the only spelling a
    /// provider permits a model to emit.
    ///
    /// `None` means refused. The caller must not fall back.
    #[must_use]
    pub fn resolve(&self, name: &str) -> Option<ToolId> {
        self.entries
            .keys()
            .find(|id| id.wire_name() == name)
            .cloned()
    }

    /// Find a tool by the spelling a **manifest** uses.
    ///
    /// The counterpart to [`resolve`](Self::resolve), which takes the spelling a
    /// *model* uses. Two spellings because a manifest reference is a URI a human
    /// reviews and a wire name is what a provider permits a model to emit — and
    /// both are derived in one place each, because they were once derived in
    /// three places in two formats.
    #[must_use]
    pub fn resolve_reference(&self, reference: &str) -> Option<ToolId> {
        self.entries
            .keys()
            .find(|id| id.reference() == reference)
            .cloned()
    }

    /// Every granted tool, for declaring to a model.
    ///
    /// Declare from here rather than from a hand-written list: offering a model
    /// a tool the catalogue does not grant produces a call that is refused after
    /// the tokens are paid for, and offering fewer than are granted hides
    /// capability for no reason. One source, so the two cannot disagree.
    pub fn granted(&self) -> impl Iterator<Item = &ToolId> {
        self.entries.keys()
    }

    /// Record what a server advertised about a tool it offers.
    ///
    /// Changes nothing about how the tool is treated. It exists so the
    /// disagreement is visible.
    #[must_use]
    pub fn observed(mut self, id: &ToolId, advertised: Advertised) -> Self {
        if let Some(entry) = self.entries.get_mut(id) {
            // Recorded and compared, never obeyed — and the "compared" half
            // must reach somebody: an advertisement that outgrows its grant
            // is the first observable move of a server going bad, and a
            // discrepancy only visible to code that calls `overclaiming()` by
            // hand is detection without delivery. A warning, not an error,
            // because the grant is the ceiling either way; what the operator
            // is being told is that the server now *wants* more than they
            // gave it.
            if advertised.overclaims(&entry.0) {
                tracing::warn!(
                    tool = %id,
                    "this tool's server now advertises more safety than the \
                     operator granted; the grant still rules, but the \
                     advertisement changed"
                );
            }
            entry.1 = advertised;
        }
        self
    }

    /// The operator's declaration, if this tool is permitted at all.
    #[must_use]
    pub fn safety(&self, id: &ToolId) -> Option<&ToolSafety> {
        self.entries.get(id).map(|(s, _)| s)
    }

    #[must_use]
    pub fn advertised(&self, id: &ToolId) -> Option<&Advertised> {
        self.entries.get(id).map(|(_, a)| a)
    }

    /// Tools where the server claims more safety than the operator granted.
    ///
    /// Worth surfacing at startup: a server that begins advertising itself as
    /// read-only after an upgrade is indistinguishable, from here, from one that
    /// has been replaced.
    pub fn overclaiming(&self) -> impl Iterator<Item = &ToolId> {
        self.entries
            .iter()
            .filter(|(_, (safety, adv))| adv.overclaims(safety))
            .map(|(id, _)| id)
    }

    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

/// Which client reaches which server.
///
/// # Why a plane needs one at all
///
/// A [`ToolId`] carries a server because two servers may both offer `transfer`
/// and they are not the same tool. Nothing enforced that: a plane held exactly
/// one [`ToolClient`] and handed it every id, so a deployment could grant
/// `tool://ledger/read` and wire a client connected to a *different* server —
/// which then answered, because a transport that never reads the server
/// component cannot tell the difference. The realistic shape is two servers with
/// a tool of the same name, where the wrong one runs and reports success.
///
/// One client per server, resolved by name, makes that unspellable. A server
/// nobody registered is [`ToolError::Unreachable`] — the honest answer, since
/// there is no transport that could carry the call.
///
/// It is also what lets a plane use more than one kind of tool at once: a
/// [`ToolBox`] of typed in-process tools and an MCP server are two transports,
/// and a plane routes to both by the server a reference names.
#[derive(Debug, Default)]
pub struct ToolRouter {
    routes: BTreeMap<String, Arc<dyn ToolClient>>,
}

impl ToolRouter {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Route one server's tools to one client.
    ///
    /// # Panics
    ///
    /// If the server is already routed. Silently replacing would make
    /// registration order decide which transport carries a call, which is the
    /// same defect this type exists to remove.
    #[must_use]
    pub fn server(mut self, name: impl Into<String>, client: Arc<dyn ToolClient>) -> Self {
        let name = name.into();
        assert!(
            !self.routes.contains_key(&name),
            "tool server '{name}' is routed twice — one of the two transports would \
             silently never be called"
        );
        self.routes.insert(name, client);
        self
    }

    /// Route every server the box implements to the box.
    ///
    /// A typed tool names its own server, so the box already knows which ones it
    /// answers for; asking a caller to repeat them would be one decision written
    /// twice.
    #[must_use]
    pub fn toolbox(self, tools: &Arc<ToolBox>) -> Self {
        let servers: Vec<String> = tools.servers().map(ToOwned::to_owned).collect();
        servers.into_iter().fold(self, |router, name| {
            router.server(name, Arc::clone(tools) as Arc<dyn ToolClient>)
        })
    }

    /// The servers this router can reach.
    pub fn servers(&self) -> impl Iterator<Item = &str> {
        self.routes.keys().map(String::as_str)
    }
}

impl ToolRouter {
    /// The transport that would carry this call, if one is routed.
    fn route(&self, tool: &ToolId) -> Option<&Arc<dyn ToolClient>> {
        self.routes.get(&tool.server)
    }
}

#[async_trait]
impl ToolClient for ToolRouter {
    async fn call(
        &self,
        tool: &ToolId,
        arguments: &Value,
        provenance: Option<&crate::core::Provenance>,
    ) -> Result<Value, ToolError> {
        let Some(client) = self.route(tool) else {
            return Err(ToolError::Unreachable {
                tool: tool.clone(),
                detail: format!(
                    "no transport is wired for tool server '{}'; this plane routes {:?}",
                    tool.server,
                    self.routes.keys().collect::<Vec<_>>()
                ),
            });
        };
        client.call(tool, arguments, provenance).await
    }

    /// Whatever the routed transport says.
    ///
    /// An unrouted server answers [`Destination::Local`] rather than refusing:
    /// there is no transport, so there is no destination to grant, and the call
    /// is about to fail as [`ToolError::Unreachable`] with the honest reason.
    /// Reporting an egress refusal for a server nobody wired would name the
    /// wrong problem.
    fn destination(&self, tool: &ToolId) -> Destination {
        self.route(tool)
            .map_or(Destination::Local, |client| client.destination(tool))
    }
}

/// One call to one tool.
///
/// Built through [`ToolCatalog`], so a tool nobody declared cannot be
/// constructed — the refusal happens before an effect exists, rather than inside
/// one.
#[derive(Debug)]
pub struct ToolCall {
    id: ToolId,
    arguments: Value,
    safety: ToolSafety,
    client: std::sync::Arc<dyn ToolClient>,
    /// Who is calling, sealed for this call. Set by the runtime via
    /// [`Effect::attach`](crate::core::Effect::attach), because it contains the
    /// effect key and an effect does not know its own.
    provenance: Option<crate::core::Provenance>,
}

impl ToolCall {
    /// Prepare a call, if the operator permits this tool.
    ///
    /// # Prefer `StepCtx::call_tool`
    ///
    /// This takes whatever catalogue it is handed, and **nothing here binds that
    /// catalogue to the manifest governing the caller**. A hand-built one —
    /// which is the obvious thing to write, and what an older version of
    /// `examples/governed_transfer.rs` demonstrated — compiles, runs, and can be
    /// *laxer* than the declaration: a [`ToolSafety::read_only`] entry for a tool
    /// the manifest calls mutating exempts it from the whole-value taint gate and
    /// carries [`Recovery::Retry`], so a timed-out money-moving call is sent
    /// again. The plane's own catalogue is refused at build for exactly that
    /// divergence; a skill's is not checked by anything.
    ///
    /// [`StepCtx::call_tool`](crate::runtime::StepCtx::call_tool) dispatches
    /// over the plane's checked catalogue and makes the drift unrepresentable.
    /// Where a skill genuinely needs its own — a catalogue assembled before a
    /// runtime exists, or one for a test — build it with
    /// [`ToolCatalog::from_manifest`], which derives the reach from the
    /// declaration rather than restating it.
    ///
    /// [`ToolSafety::read_only`]: ToolSafety::read_only
    /// [`Recovery::Retry`]: crate::core::Recovery::Retry
    ///
    /// # Errors
    ///
    /// [`ToolError::Unreachable`] when the tool is not in the catalogue. It is
    /// deliberately not a separate "unknown tool" error at the call site: from
    /// the run's point of view an undeclared tool is one it cannot reach, and
    /// treating it as anything softer invites a fallback.
    pub fn prepare(
        catalog: &ToolCatalog,
        client: std::sync::Arc<dyn ToolClient>,
        id: ToolId,
        arguments: Value,
        egress: Option<&crate::core::Egress>,
    ) -> Result<Self, ToolError> {
        let Some(safety) = catalog.safety(&id) else {
            return Err(ToolError::Unreachable {
                detail: "this tool is not in the catalogue; a tool nobody declared is a \
                         tool nobody has reasoned about"
                    .into(),
                tool: id,
            });
        };
        // Refused **before the effect exists**, so nothing leaves, nothing is
        // journaled and nothing is metered — the same point in the sequence at
        // which a model driver refuses an ungranted base URL. A `Local`
        // transport has no host to grant and is not judged: the allowlist
        // decides where traffic may go, and this traffic goes nowhere of the
        // plane's choosing.
        if let Some(egress) = egress
            && let Some(host) = client.destination(&id).host().map(ToOwned::to_owned)
            && let Err(refused) = egress.permits(Some(&host))
        {
            return Err(ToolError::Unreachable {
                detail: format!("the transport for server '{}' reaches {refused}", id.server),
                tool: id,
            });
        }
        Ok(Self {
            safety: safety.clone(),
            id,
            arguments,
            client,
            provenance: None,
        })
    }
}

#[async_trait]
impl Effect for ToolCall {
    fn gen_ai_operation(&self) -> Option<&'static str> {
        Some(crate::runtime::telemetry::GEN_AI_EXECUTE_TOOL)
    }

    type Output = Value;

    fn descriptor(&self) -> EffectDescriptor {
        // The server and tool are part of the key, so two servers offering the
        // same tool name are two different effects and cannot replay into each
        // other.
        EffectDescriptor::new(
            "tool.call",
            serde_json::json!({
                "server": self.id.server,
                "tool": self.id.tool,
                "arguments": self.arguments,
                // Sorted here, not merely by the builder. `protect()` sorts,
                // but a `ToolSafety` built by struct literal or deserialised
                // from config never passes through it — and declaration order
                // would then change the **effect key**, so the same call
                // replayed against a differently-ordered catalogue would report
                // divergence. Canonicalising at the one place the value is
                // hashed makes the order unobservable.
                "protected_fields": sorted_fields(&self.safety.protected_fields),
            }),
        )
    }

    fn mutates(&self) -> bool {
        self.safety.mutates
    }

    fn recovery(&self) -> Recovery {
        self.safety.recovery.clone()
    }

    fn retry(&self) -> RetryPolicy {
        self.safety.retry
    }

    fn max_sensitivity(&self) -> Sensitivity {
        self.safety.max_sensitivity
    }

    fn sink_arguments(&self) -> Option<&Value> {
        Some(&self.arguments)
    }

    fn protected_fields(&self) -> &[ProtectedField] {
        &self.safety.protected_fields
    }

    fn output_sensitivity(&self) -> Sensitivity {
        self.safety.output_sensitivity
    }

    /// A tool result is the outside world's data.
    ///
    /// Stated rather than inherited, because this is the one effect where a
    /// reader is most likely to wonder whether the catalogue could relax it. It
    /// cannot: the catalogue governs authority, not provenance.
    fn trust(&self) -> Trust {
        Trust::Untrusted
    }

    /// The tool's own reference, so a source rule can name *this* tool.
    ///
    /// `tool://server/name` — the spelling a manifest grants and a reviewer
    /// reads. Under the family-level default every granted tool answered as
    /// `effect:tool.call`, so a `ProtectedField::from_sources` rule could not
    /// distinguish the CRM lookup from the ticket search: "the recipient must
    /// come from the CRM" was unsatisfiable strictly, and satisfiable loosely
    /// by whichever tool an injected prompt reached first.
    fn source(&self) -> crate::core::SourceId {
        crate::core::SourceId::new(self.id.reference())
    }

    fn attach(&mut self, provenance: &crate::core::Provenance) {
        self.provenance = Some(provenance.clone());
    }

    async fn perform(&self) -> Result<Value, EffectError> {
        self.client
            .call(&self.id, &self.arguments, self.provenance.as_ref())
            .await
            .map_err(|e| {
                let detail = e.to_string();
                // The disposition is what decides whether this is ever tried
                // again, so the mapping is explicit rather than a default arm.
                match e.disposition() {
                    Disposition::DidNotHappen => EffectError::Rejected(detail),
                    Disposition::InDoubt => EffectError::Interrupted {
                        driver: self.id.to_string(),
                        detail,
                    },
                    Disposition::Landed => EffectError::Performed(detail),
                }
            })
    }
}

#[cfg(test)]
mod overclaim_tests {
    use super::*;

    /// Each safety claim is checked on its own, so removing any one clause is a
    /// real regression rather than one masked by another firing on the same
    /// fixture. `destructive == Some(false)` on a mutating grant is the clause
    /// that was recorded and never read.
    #[test]
    fn a_server_claiming_more_safety_than_granted_is_flagged_per_clause() {
        let mutating = ToolSafety::default(); // mutates: true, Recovery::default
        let operator_needs_operator = ToolSafety::default().recovery(Recovery::RequiresOperator);

        // read-only lie
        assert!(
            Advertised {
                read_only: Some(true),
                ..Advertised::default()
            }
            .overclaims(&mutating)
        );

        // not-destructive lie — the clause under test
        assert!(
            Advertised {
                destructive: Some(false),
                ..Advertised::default()
            }
            .overclaims(&mutating)
        );

        // idempotent lie, but only when repeating is unsafe
        assert!(
            Advertised {
                idempotent: Some(true),
                ..Advertised::default()
            }
            .overclaims(&operator_needs_operator)
        );

        // Honest advertisements and honest silence do not flag.
        assert!(
            !Advertised {
                destructive: Some(true),
                read_only: Some(false),
                idempotent: Some(false),
            }
            .overclaims(&mutating)
        );
        assert!(!Advertised::default().overclaims(&mutating));
        assert!(
            !Advertised {
                read_only: Some(true),
                destructive: Some(false),
                idempotent: Some(true),
            }
            .overclaims(&ToolSafety::read_only())
        );
    }
}