Skip to main content

agent_engine/tools/
activation.rs

1//! `SessionToolSet` — per-session core + exact activated tools (Task 15,
2//! spec §7.1, §4.2).
3//!
4//! This is bookkeeping only, not yet an execution gate: it records which
5//! configured core tools and which exactly-activated deferred tools belong to
6//! one session, pinned to the catalog generation and schema digests they were
7//! granted against. It never issues model-facing activations, exposes
8//! schemas, initializes implementations, or enforces execution.
9//!
10//! Isolation invariants:
11//! - a new session's set starts with zero activations and inherits nothing
12//!   from any other set;
13//! - activation requires an exact [`SessionActivationGrant`] for this
14//!   session, tool, current catalog generation, and current schema digest;
15//! - every rejection is typed and leaves the set unchanged (no partial
16//!   mutation);
17//! - catalog drift is represented (`is_stale`), never silently absorbed.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::sync::Arc;
21
22use agent_core::BoundedText;
23use thiserror::Error;
24
25use super::catalog::{
26    CapabilityRecord, CapabilitySource, CatalogGeneration, SchemaDigest, SessionActivationGrant,
27    ToolCatalog, ToolEffect, ToolId, TrustProvenance,
28};
29use super::{Tool, ToolRegistry};
30
31pub use agent_core::orchestration::capability::{SessionId, SessionIdError};
32
33/// Runtime-lease placeholder metadata (spec §7.4 territory). Real lease
34/// acquisition/expiry arrives with MCP/extension activation work; until then
35/// every activation records that no runtime has been acquired.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
37#[serde(rename_all = "snake_case")]
38pub enum RuntimeLease {
39    NotAcquired,
40}
41
42/// One exact activation held by a session: the validated grant plus lease
43/// placeholder metadata. The grant carries the pinned catalog generation and
44/// schema digest; the trust provenance of the catalog record is pinned
45/// engine-side at activation time (the grant type in agent-core is
46/// deliberately untouched) so execution can detect a record that was
47/// removed and re-added under a different — even internally coherent —
48/// source/provenance.
49#[derive(Clone, Debug)]
50pub struct ActivatedTool {
51    grant: SessionActivationGrant,
52    provenance: TrustProvenance,
53    lease: RuntimeLease,
54}
55
56impl ActivatedTool {
57    pub fn grant(&self) -> &SessionActivationGrant {
58        &self.grant
59    }
60
61    pub fn schema_digest(&self) -> &SchemaDigest {
62        self.grant.schema_digest()
63    }
64
65    /// The trust provenance of the catalog record at activation time.
66    pub fn provenance(&self) -> &TrustProvenance {
67        &self.provenance
68    }
69
70    pub fn catalog_generation(&self) -> CatalogGeneration {
71        self.grant.catalog_generation()
72    }
73
74    pub fn lease(&self) -> RuntimeLease {
75        self.lease
76    }
77}
78
79/// Typed failure for [`SessionToolSet::revoke_exact`].
80#[derive(Clone, Debug, Error, PartialEq, Eq)]
81pub enum ExactRevocationError {
82    #[error("cannot revoke a configured core tool: {0}")]
83    CoreTool(ToolId),
84    #[error("tool is not activated in this session: {0}")]
85    NotActivated(ToolId),
86}
87
88/// Typed failure for constructing a [`SessionToolSet`].
89#[derive(Clone, Debug, Error, PartialEq, Eq)]
90pub enum SessionToolSetError {
91    #[error("configured core tool is not in the catalog: {0}")]
92    UnknownCoreTool(ToolId),
93}
94
95/// Typed failure for activating a tool in a [`SessionToolSet`]. Every
96/// variant fails closed without mutating the set.
97#[derive(Clone, Debug, Error, PartialEq, Eq)]
98pub enum ActivationError {
99    #[error("activation grant was issued for another session")]
100    SessionMismatch,
101    #[error("activation grant names a tool the catalog does not know: {0}")]
102    UnknownTool(ToolId),
103    #[error(
104        "activation grant generation {} does not match current catalog generation {}",
105        grant.value(),
106        catalog.value()
107    )]
108    StaleGeneration {
109        grant: CatalogGeneration,
110        catalog: CatalogGeneration,
111    },
112    #[error("activation grant schema digest does not match the catalog record for {0}")]
113    DigestMismatch(ToolId),
114    #[error("tool is already activated for this session: {0}")]
115    AlreadyActivated(ToolId),
116    #[error("tool is already in this session's configured core set: {0}")]
117    AlreadyCore(ToolId),
118    #[error(
119        "session tool set snapshot at generation {} is stale against catalog generation {}; rebuild it",
120        set.value(),
121        catalog.value()
122    )]
123    StaleSnapshot {
124        set: CatalogGeneration,
125        catalog: CatalogGeneration,
126    },
127}
128
129/// Per-tool pins captured when a core tool enters a session's set: the
130/// schema digest AND the trust provenance of the catalog record at build
131/// time. Pinning provenance (not just the digest) closes the coherent-
132/// imposter hole: a tool removed and re-added under the SAME `ToolId` with a
133/// schema-identical body but a different, internally consistent
134/// source/provenance must still be denied at execution time.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct CorePin {
137    schema_digest: SchemaDigest,
138    provenance: TrustProvenance,
139}
140
141impl CorePin {
142    pub fn schema_digest(&self) -> &SchemaDigest {
143        &self.schema_digest
144    }
145
146    pub fn provenance(&self) -> &TrustProvenance {
147        &self.provenance
148    }
149}
150
151/// Why an activation was not carried across a round-top rebuild.
152#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum DropReason {
155    /// The tool is no longer in the catalog.
156    Removed,
157    /// The record's schema digest or trust provenance changed.
158    Drifted,
159}
160
161/// One activation dropped by [`SessionToolSet::rebuilt_for_catalog`].
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct DroppedActivation {
164    pub id: ToolId,
165    pub reason: DropReason,
166}
167
168/// Kill-switch for carry-forward: `SYNAPS_TOOLSET_CARRY_FORWARD=0` restores
169/// the zero-inherit rebuild (every catalog mutation wipes activations).
170pub fn carry_forward_enabled() -> bool {
171    std::env::var("SYNAPS_TOOLSET_CARRY_FORWARD").as_deref() != Ok("0")
172}
173
174/// One exact activation as plain, serializable data: what a session hands
175/// to its next runtime across a daemon reload (`SessionReloadRecord`) or a
176/// park. It carries the PINS the activation was granted against — never a
177/// grant: the next runtime has a new tool-session id and re-issues a grant
178/// only after [`SessionToolSet::restore_carried`] re-validated the pins
179/// against its own catalog. Untrusted on the way back in (it round-trips
180/// through a file), which is why every field is re-checked, not believed.
181#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
182pub struct CarriedActivation {
183    /// Canonical `ToolId` (`namespace:name`).
184    pub tool_id: String,
185    /// Lowercase hex schema digest pinned at activation time.
186    pub schema_digest: String,
187    /// Trust provenance pinned at activation time.
188    pub provenance: CarriedProvenance,
189}
190
191/// Serializable mirror of [`TrustProvenance`] for [`CarriedActivation`].
192/// Only ever COMPARED with the current catalog record's provenance; never
193/// converted back into a `TrustProvenance`, so wire data cannot mint one.
194#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
195#[serde(tag = "kind", rename_all = "snake_case")]
196pub enum CarriedProvenance {
197    BuiltinRuntime,
198    ExtensionManifest { extension_id: String },
199    McpConfig { server_id: String },
200    PluginConfig { plugin_id: String },
201    Unverified,
202}
203
204impl From<&TrustProvenance> for CarriedProvenance {
205    fn from(p: &TrustProvenance) -> Self {
206        match p {
207            TrustProvenance::BuiltinRuntime => Self::BuiltinRuntime,
208            TrustProvenance::ExtensionManifest { extension_id } => Self::ExtensionManifest {
209                extension_id: extension_id.clone(),
210            },
211            TrustProvenance::McpConfig { server_id } => Self::McpConfig {
212                server_id: server_id.clone(),
213            },
214            TrustProvenance::PluginConfig { plugin_id } => Self::PluginConfig {
215                plugin_id: plugin_id.clone(),
216            },
217            TrustProvenance::Unverified => Self::Unverified,
218        }
219    }
220}
221
222/// Why a carried activation was not restored.
223#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
224#[serde(rename_all = "snake_case")]
225pub enum RestoreDropReason {
226    /// Not in the current catalog (or not a valid `ToolId`).
227    Removed,
228    /// Schema digest, trust provenance or source trust changed.
229    Drifted,
230    /// `tools.activation_confirm = deny` in the restoring runtime.
231    Denied,
232    /// The target set was not built against the current catalog.
233    StaleSet,
234}
235
236/// Outcome of [`SessionToolSet::restore_carried`]: what was restored and
237/// what was dropped (raw id + reason), for logging and the session notice.
238#[derive(Clone, Debug, Default, PartialEq, Eq)]
239pub struct RestoreReport {
240    pub restored: Vec<ToolId>,
241    pub dropped: Vec<(String, RestoreDropReason)>,
242}
243
244/// The small configured core set plus exact activated deferred tools for one
245/// session, pinned to the catalog generation it was built against. Core
246/// tools are pinned with the schema digest AND trust provenance of their
247/// catalog record at build time, so later drift is detectable per tool, not
248/// just per generation.
249#[derive(Clone, Debug)]
250pub struct SessionToolSet {
251    session: SessionId,
252    catalog_generation: CatalogGeneration,
253    core: BTreeMap<ToolId, CorePin>,
254    activated: BTreeMap<ToolId, ActivatedTool>,
255    /// Session schema-generation counter (spec §7.7): advances by exactly
256    /// one for every successful nonempty activation batch (a single
257    /// activation is a batch of one). Deterministic bookkeeping only; it
258    /// exposes nothing and grants nothing.
259    schema_generation: u64,
260    /// Tools this session is not offered although the registry has them
261    /// ([`Self::withholding`]). The registry is shared by every session on
262    /// the host, so what one session may not see is recorded here, on its
263    /// own set, and never removed there.
264    withheld: BTreeSet<ToolId>,
265}
266
267impl SessionToolSet {
268    /// Build a fresh set for one session. Every configured core id must
269    /// exist in the catalog (typed failure otherwise) and its schema digest
270    /// AND trust provenance are pinned from the catalog record; the set
271    /// starts with zero activations — nothing is inherited from any other
272    /// session.
273    pub fn new(
274        session: SessionId,
275        core: impl IntoIterator<Item = ToolId>,
276        catalog: &ToolCatalog,
277    ) -> Result<Self, SessionToolSetError> {
278        let mut validated = BTreeMap::new();
279        for id in core {
280            let Some(record) = catalog.get(&id) else {
281                return Err(SessionToolSetError::UnknownCoreTool(id));
282            };
283            validated.insert(
284                id,
285                CorePin {
286                    schema_digest: record.schema_digest().clone(),
287                    provenance: record.provenance().clone(),
288                },
289            );
290        }
291        Ok(Self {
292            session,
293            catalog_generation: catalog.generation(),
294            core: validated,
295            activated: BTreeMap::new(),
296            schema_generation: 0,
297            withheld: BTreeSet::new(),
298        })
299    }
300
301    /// This set without `ids`, for good: each leaves the core and the
302    /// activations, and from then on is to this session a tool that does
303    /// not exist. It cannot be activated, carried or restored into the set
304    /// ([`Self::validate_grant`], [`Self::admit_pinned`]), so the execution
305    /// gate denies it and no schema projection of this set lists it, while
306    /// the registry and every other session keep it. Ids the catalog does
307    /// not know are remembered too: a later registration under one of them
308    /// stays withheld.
309    pub fn withholding(mut self, ids: impl IntoIterator<Item = ToolId>) -> Self {
310        for id in ids {
311            self.core.remove(&id);
312            self.activated.remove(&id);
313            self.withheld.insert(id);
314        }
315        self
316    }
317
318    /// The tools this session is not offered ([`Self::withholding`]).
319    pub fn withheld(&self) -> impl Iterator<Item = &ToolId> {
320        self.withheld.iter()
321    }
322
323    pub fn is_withheld(&self, id: &ToolId) -> bool {
324        self.withheld.contains(id)
325    }
326
327    pub fn session(&self) -> &SessionId {
328        &self.session
329    }
330
331    /// Default per-stream-session set (Task 16 behavior preservation): the
332    /// core set is EXACTLY every currently cataloged capability with
333    /// verified provenance — builtins plus already-loaded trusted
334    /// extension/MCP/plugin tools. `Unverified` capabilities are excluded
335    /// (denied-by-default at the gate), and the set carries zero
336    /// activations. Deterministic: ids come straight from the catalog in
337    /// `ToolId` order, so rebuilding after a catalog mutation yields the
338    /// same set for the same catalog state.
339    pub fn default_core_for_catalog(session: SessionId, catalog: &ToolCatalog) -> Self {
340        let core: Vec<ToolId> = catalog
341            .iter()
342            .filter(|record| record.provenance() != &TrustProvenance::Unverified)
343            .map(|record| record.id().clone())
344            .collect();
345        Self::new(session, core, catalog)
346            .expect("core ids are drawn from the catalog itself and must all resolve")
347    }
348
349    /// Deterministic minimal core for Task 18 progressive disclosure. Only
350    /// essential local operations plus discovery/authorization gateways are
351    /// eligible; missing/disabled gateways are skipped rather than invented.
352    /// Specialized subagent lifecycle, extension, MCP, shell-session, and
353    /// dynamically registered tools remain deferred until exact activation.
354    pub fn progressive_core_for_catalog(session: SessionId, catalog: &ToolCatalog) -> Self {
355        Self::progressive_core_with(session, catalog, &[])
356    }
357
358    /// [`Self::progressive_core_for_catalog`] plus the operator's
359    /// `tools.always_loaded` ids. A listed id joins the core only when it is
360    /// in `catalog` AND activation could grant it ([`is_activatable_record`]:
361    /// verified provenance matching its source; never `Unverified`). An id
362    /// not (yet) cataloged is skipped, so a tool registered after the
363    /// session started joins at the next round-top rebuild, and one that
364    /// disappears leaves the core with the catalog (fail-closed).
365    pub fn progressive_core_with(
366        session: SessionId,
367        catalog: &ToolCatalog,
368        always_loaded: &[ToolId],
369    ) -> Self {
370        const ESSENTIAL_BUILTINS: &[&str] = &[
371            "bash",
372            "read",
373            "write",
374            "edit",
375            "multi_edit",
376            "grep",
377            "find",
378            "ls",
379            "search_tools",
380            "activate_tools",
381            "search_skills",
382            "load_skill",
383        ];
384        let core = ESSENTIAL_BUILTINS
385            .iter()
386            .map(|name| ToolId::builtin(name))
387            .filter(|id| catalog.get(id).is_some())
388            .chain(
389                always_loaded
390                    .iter()
391                    .filter(|id| catalog.get(id).is_some_and(is_activatable_record))
392                    .cloned(),
393            )
394            .collect::<Vec<_>>();
395        Self::new(session, core, catalog)
396            .expect("progressive core ids are filtered through the catalog")
397    }
398
399    /// Round-top rebuild against a NEWER catalog (`runtime/stream.rs`):
400    /// the core is re-derived exactly as a fresh build would derive it, and
401    /// every prior exact activation whose CURRENT catalog record still
402    /// matches its pinned (schema digest, trust provenance) is re-issued a
403    /// grant at the new generation. Activations whose record is gone or
404    /// drifted are dropped and reported.
405    ///
406    /// This is NOT a gate change: a carried activation is exactly what a
407    /// user re-activation of the same tool would produce against this
408    /// catalog, and `ExecutionGate::authorize` still checks digest and
409    /// provenance against the current record on every call.
410    pub fn rebuilt_for_catalog(
411        &self,
412        catalog: &ToolCatalog,
413        progressive: bool,
414    ) -> (Self, Vec<DroppedActivation>) {
415        let next = if progressive {
416            Self::progressive_core_for_catalog(self.session.clone(), catalog)
417        } else {
418            Self::default_core_for_catalog(self.session.clone(), catalog)
419        };
420        let next = next.withholding(self.withheld.iter().cloned());
421        self.carry_activations_into(next, catalog)
422    }
423
424    /// Carry this set's exact activations into `next` — a freshly built core
425    /// for the SAME session against `catalog` — with exactly the checks
426    /// [`Self::rebuilt_for_catalog`] applies (pinned digest + provenance
427    /// unchanged and source trust intact; promoted-to-core ids are not
428    /// carried; drifted/removed ones are dropped and reported). Shared by the
429    /// round-top rebuild and the turn-start reconciliation of a retained
430    /// set, so the two paths can never drift.
431    ///
432    /// A `next` built for a different session inherits nothing: grants never
433    /// cross sessions.
434    pub fn carry_activations_into(
435        &self,
436        mut next: SessionToolSet,
437        catalog: &ToolCatalog,
438    ) -> (Self, Vec<DroppedActivation>) {
439        if next.session != self.session {
440            return (next, Vec::new());
441        }
442        let mut dropped = Vec::new();
443        for old in self.activated.values() {
444            let id = old.grant().tool_id().clone();
445            if let Err(reason) = next.admit_pinned(&id, catalog, |rec| {
446                rec.schema_digest() == old.schema_digest() && rec.provenance() == old.provenance()
447            }) {
448                dropped.push(DroppedActivation { id, reason });
449            }
450        }
451        // Session schema-generation is bookkeeping for "activation batches
452        // applied"; carrying is not a batch: keep the old counter, +1 iff
453        // anything was dropped (the exposed schema changed).
454        next.schema_generation = self.schema_generation + u64::from(!dropped.is_empty());
455        (next, dropped)
456    }
457
458    /// Admit one prior activation of THIS session into this set against
459    /// `catalog` — the single check shared by turn-to-turn carrying
460    /// ([`Self::carry_activations_into`]) and cross-image restore
461    /// ([`Self::restore_carried`]), so the two can never drift:
462    ///
463    /// - uncataloged → `Removed`;
464    /// - already core → `Ok` (still on the wire, nothing to record);
465    /// - `pin_matches(record)` (the pinned schema digest AND trust
466    ///   provenance are unchanged) AND the record's source still agrees with
467    ///   its provenance (`check_source_trust`, as `activate_many`) → a grant
468    ///   at the CURRENT catalog generation is recorded;
469    /// - anything else → `Drifted`, never silently re-granted.
470    ///
471    /// Already-activated ids are left as they are (`Ok`). Does not touch the
472    /// schema-generation counter; callers own that bookkeeping.
473    fn admit_pinned(
474        &mut self,
475        id: &ToolId,
476        catalog: &ToolCatalog,
477        pin_matches: impl FnOnce(&CapabilityRecord) -> bool,
478    ) -> Result<(), DropReason> {
479        // Withheld from this session: to it, a tool that is not there.
480        if self.withheld.contains(id) {
481            return Err(DropReason::Removed);
482        }
483        let Some(rec) = catalog.get(id) else {
484            return Err(DropReason::Removed);
485        };
486        if self.core.contains_key(id) || self.activated.contains_key(id) {
487            return Ok(());
488        }
489        if !(pin_matches(rec) && check_source_trust(rec).is_ok()) {
490            return Err(DropReason::Drifted);
491        }
492        let grant = SessionActivationGrant::new(
493            self.session.as_str(),
494            id.clone(),
495            catalog.generation(),
496            rec.schema_digest().clone(),
497        )
498        .expect("session id was valid when the set was built");
499        self.activated.insert(
500            id.clone(),
501            ActivatedTool {
502                grant,
503                provenance: rec.provenance().clone(),
504                lease: RuntimeLease::NotAcquired,
505            },
506        );
507        Ok(())
508    }
509
510    /// This session's exact activations as plain data, in `ToolId` order —
511    /// what a daemon reload or a park hands to the session's next runtime
512    /// (see [`CarriedActivation`]). Core tools are not included: the next
513    /// runtime re-derives its core from its own catalog.
514    pub fn carried_activations(&self) -> Vec<CarriedActivation> {
515        self.activated
516            .values()
517            .map(|a| CarriedActivation {
518                tool_id: a.grant().tool_id().as_str().to_string(),
519                schema_digest: a.schema_digest().as_hex().to_string(),
520                provenance: CarriedProvenance::from(a.provenance()),
521            })
522            .collect()
523    }
524
525    /// Restore activations this SAME conversation held in a previous
526    /// runtime (a daemon reload's previous image, or the runtime a park
527    /// dropped) into this set, which must be freshly built against
528    /// `catalog` for the new runtime's tool session.
529    ///
530    /// This continues grants the session already had; it is not a new
531    /// grant. Each entry is re-validated against the CURRENT catalog with
532    /// exactly the pins [`ToolRegistry::session_tools_schema`] and the
533    /// execution gate check — schema digest AND trust provenance — plus
534    /// source trust; an entry that is unparsable, uncataloged, or drifted
535    /// is dropped fail-closed and reported, never re-granted. Restored
536    /// grants are issued for THIS set's session at the current generation,
537    /// so the execution gate authorizes them like any other activation.
538    ///
539    /// Activation policy (`tools.activation_confirm`) is the caller's
540    /// decision (see `runtime::stream::restore_pending_activations`).
541    /// Advances the schema-generation counter by one iff anything was
542    /// restored (one batch), like `activate_many`.
543    pub fn restore_carried(
544        &mut self,
545        carried: &[CarriedActivation],
546        catalog: &ToolCatalog,
547    ) -> RestoreReport {
548        let mut report = RestoreReport::default();
549        if self.catalog_generation != catalog.generation() {
550            // A stale snapshot cannot take grants (as `validate_grant`):
551            // the caller rebuilds against `catalog` first.
552            report.dropped = carried
553                .iter()
554                .map(|c| (c.tool_id.clone(), RestoreDropReason::StaleSet))
555                .collect();
556            return report;
557        }
558        for c in carried {
559            let Ok(id) = ToolId::parse(&c.tool_id) else {
560                report
561                    .dropped
562                    .push((c.tool_id.clone(), RestoreDropReason::Removed));
563                continue;
564            };
565            let was_new = !self.core.contains_key(&id) && !self.activated.contains_key(&id);
566            match self.admit_pinned(&id, catalog, |rec| {
567                rec.schema_digest().as_hex() == c.schema_digest
568                    && CarriedProvenance::from(rec.provenance()) == c.provenance
569            }) {
570                Ok(()) if was_new && self.activated.contains_key(&id) => {
571                    report.restored.push(id);
572                }
573                Ok(()) => {}
574                Err(DropReason::Removed) => {
575                    report
576                        .dropped
577                        .push((c.tool_id.clone(), RestoreDropReason::Removed));
578                }
579                Err(DropReason::Drifted) => {
580                    report
581                        .dropped
582                        .push((c.tool_id.clone(), RestoreDropReason::Drifted));
583                }
584            }
585        }
586        if !report.restored.is_empty() {
587            self.schema_generation += 1;
588        }
589        report
590    }
591
592    /// Typed EXACT revocation (Task 19 grant invalidation): remove one
593    /// non-core exact activation from this session's set. The session
594    /// schema generation advances by exactly one IFF an activation was
595    /// removed; core tools and unknown/never-activated ids fail typed with
596    /// zero mutation. Never touches the catalog or sibling activations.
597    pub fn revoke_exact(&mut self, id: &ToolId) -> Result<(), ExactRevocationError> {
598        if self.core.contains_key(id) {
599            return Err(ExactRevocationError::CoreTool(id.clone()));
600        }
601        if self.activated.remove(id).is_none() {
602            return Err(ExactRevocationError::NotActivated(id.clone()));
603        }
604        self.schema_generation += 1;
605        Ok(())
606    }
607
608    /// The catalog generation this set snapshot was built against.
609    pub fn catalog_generation(&self) -> CatalogGeneration {
610        self.catalog_generation
611    }
612
613    /// True when the catalog has mutated since this set was built. A stale
614    /// set must be rebuilt/revalidated, not silently used.
615    pub fn is_stale(&self, catalog: &ToolCatalog) -> bool {
616        self.catalog_generation != catalog.generation()
617    }
618
619    /// Deterministic projection of configured core ids (ToolId order).
620    pub fn core_ids(&self) -> impl Iterator<Item = &ToolId> {
621        self.core.keys()
622    }
623
624    pub fn is_core(&self, id: &ToolId) -> bool {
625        self.core.contains_key(id)
626    }
627
628    /// The schema digest pinned for a configured core tool at build time,
629    /// or `None` when the id is not in this session's core set.
630    pub fn core_schema_digest(&self, id: &ToolId) -> Option<&SchemaDigest> {
631        self.core.get(id).map(CorePin::schema_digest)
632    }
633
634    /// The full (digest, provenance) pin for a configured core tool, or
635    /// `None` when the id is not in this session's core set.
636    pub fn core_pin(&self, id: &ToolId) -> Option<&CorePin> {
637        self.core.get(id)
638    }
639
640    /// Deterministic projection of exact activations (ToolId order).
641    pub fn activated(&self) -> impl Iterator<Item = &ActivatedTool> {
642        self.activated.values()
643    }
644
645    pub fn activation(&self, id: &ToolId) -> Option<&ActivatedTool> {
646        self.activated.get(id)
647    }
648
649    /// Session schema-generation counter (spec §7.7): the number of
650    /// successful nonempty activation batches applied to this set. A
651    /// deterministic bulk `activate_many` advances it by exactly one.
652    pub fn schema_generation(&self) -> u64 {
653        self.schema_generation
654    }
655
656    /// Validate one grant against this set and the current catalog without
657    /// mutating anything. Shared by [`Self::activate`] and
658    /// [`Self::activate_many`] so single and bulk paths can never drift.
659    fn validate_grant(
660        &self,
661        grant: &SessionActivationGrant,
662        catalog: &ToolCatalog,
663    ) -> Result<(), ActivationError> {
664        if grant.session_id() != self.session.as_str() {
665            return Err(ActivationError::SessionMismatch);
666        }
667        let tool_id = grant.tool_id().clone();
668        // Withheld from this session: refused as a tool that is not there,
669        // which is what it was when it was removed from the registry instead.
670        if self.withheld.contains(&tool_id) {
671            return Err(ActivationError::UnknownTool(tool_id));
672        }
673        let record = catalog
674            .get(&tool_id)
675            .ok_or_else(|| ActivationError::UnknownTool(tool_id.clone()))?;
676        if grant.catalog_generation() != catalog.generation() {
677            return Err(ActivationError::StaleGeneration {
678                grant: grant.catalog_generation(),
679                catalog: catalog.generation(),
680            });
681        }
682        if grant.schema_digest() != record.schema_digest() {
683            return Err(ActivationError::DigestMismatch(tool_id));
684        }
685        // A fresh grant cannot rescue a stale set snapshot: the set itself
686        // must be rebuilt against the current catalog first.
687        if self.catalog_generation != catalog.generation() {
688            return Err(ActivationError::StaleSnapshot {
689                set: self.catalog_generation,
690                catalog: catalog.generation(),
691            });
692        }
693        // Exact-tuple re-check through the grant's own covers() so the two
694        // validation paths can never drift apart.
695        debug_assert!(grant.covers(
696            self.session.as_str(),
697            &tool_id,
698            catalog.generation(),
699            record.schema_digest(),
700        ));
701        if self.activated.contains_key(&tool_id) {
702            return Err(ActivationError::AlreadyActivated(tool_id));
703        }
704        // Core tools are always available to the session; re-recording one
705        // as a deferred activation would create two conflicting bookkeeping
706        // entries for the same capability.
707        if self.core.contains_key(&tool_id) {
708            return Err(ActivationError::AlreadyCore(tool_id));
709        }
710        Ok(())
711    }
712
713    /// Record one exact activation. This is set/test/bootstrap plumbing, not
714    /// a model-facing activation flow: the grant must already exist and must
715    /// match this session, a cataloged tool, the CURRENT catalog generation,
716    /// and the CURRENT schema digest exactly. Any drift — foreign session,
717    /// unknown tool, stale generation, changed digest, stale set snapshot,
718    /// duplicate activation, core-set membership — fails typed before any
719    /// mutation, so a failed call leaves the set byte-for-byte unchanged. No
720    /// implementation is constructed, no process started, no schema exposed.
721    /// A successful call is a nonempty batch of one: the session
722    /// schema-generation advances by exactly one.
723    pub fn activate(
724        &mut self,
725        grant: SessionActivationGrant,
726        catalog: &ToolCatalog,
727    ) -> Result<(), ActivationError> {
728        self.validate_grant(&grant, catalog)?;
729        let provenance = catalog
730            .get(grant.tool_id())
731            .expect("validate_grant verified the record exists")
732            .provenance()
733            .clone();
734        self.activated.insert(
735            grant.tool_id().clone(),
736            ActivatedTool {
737                grant,
738                provenance,
739                lease: RuntimeLease::NotAcquired,
740            },
741        );
742        self.schema_generation += 1;
743        Ok(())
744    }
745
746    /// Deterministic atomic bulk activation (spec §7.7): validate EVERY
747    /// requested grant first — duplicates within the batch, unknown ids,
748    /// core-set ids, stale generations, digest mismatches, stale set
749    /// snapshots, and untrusted/inconsistent source provenance all fail
750    /// typed with ZERO partial mutation — then apply in stable `ToolId`
751    /// order and advance the session schema-generation by exactly one. An
752    /// empty batch is a no-op that advances nothing. Returns the number of
753    /// activations applied.
754    pub fn activate_many(
755        &mut self,
756        grants: Vec<SessionActivationGrant>,
757        catalog: &ToolCatalog,
758    ) -> Result<usize, BulkActivationError> {
759        if grants.is_empty() {
760            return Ok(0);
761        }
762        let mut seen: std::collections::BTreeSet<ToolId> = std::collections::BTreeSet::new();
763        for grant in &grants {
764            if !seen.insert(grant.tool_id().clone()) {
765                return Err(BulkActivationError::DuplicateRequest(
766                    grant.tool_id().clone(),
767                ));
768            }
769            self.validate_grant(grant, catalog)
770                .map_err(BulkActivationError::Grant)?;
771            // Source trust re-check before ANY grant is applied: an
772            // unverified or provenance-inconsistent capability rejects the
773            // whole batch (validate_grant proved the record exists).
774            let record = catalog
775                .get(grant.tool_id())
776                .expect("validate_grant verified the record exists");
777            check_source_trust(record).map_err(|err| match err {
778                ToolAuthorizationError::SourceProvenanceMismatch(id) => {
779                    BulkActivationError::SourceProvenanceMismatch(id)
780                }
781                // check_source_trust only produces UntrustedSource or
782                // SourceProvenanceMismatch; anything else maps to the
783                // conservative untrusted denial for this id.
784                _ => BulkActivationError::UntrustedSource(grant.tool_id().clone()),
785            })?;
786        }
787        // All grants validated: apply in stable ToolId order.
788        let mut ordered = grants;
789        ordered.sort_by(|a, b| a.tool_id().cmp(b.tool_id()));
790        let applied = ordered.len();
791        for grant in ordered {
792            let provenance = catalog
793                .get(grant.tool_id())
794                .expect("validate_grant verified the record exists")
795                .provenance()
796                .clone();
797            self.activated.insert(
798                grant.tool_id().clone(),
799                ActivatedTool {
800                    grant,
801                    provenance,
802                    lease: RuntimeLease::NotAcquired,
803                },
804            );
805        }
806        // Exactly ONE schema-generation advance for the whole batch.
807        self.schema_generation += 1;
808        Ok(applied)
809    }
810}
811
812// ── ExecutionGate (Task 16, spec §7.1 / §4.2) ───────────────────────────────
813
814/// Byte budget for wire/tool names echoed inside typed denials. Denials are
815/// static, bounded, metadata-only strings: hostile oversize wire names must
816/// never be reflected verbatim.
817pub const WIRE_NAME_MAX_BYTES: usize = 128;
818
819fn bounded_wire_name(raw: &str) -> String {
820    BoundedText::new(raw, WIRE_NAME_MAX_BYTES).text
821}
822
823/// Typed resolution of an incoming API/wire tool name to the exact live
824/// runtime tool identity: the wire name as received (bounded), the runtime
825/// name it deterministically maps to (via the registry's sanitized-name
826/// reverse mapping), and the exact catalog [`ToolId`] derived from the live
827/// tool instance itself — never from the alias. Aliases and collision
828/// suffixes therefore cannot select a different identity than the tool they
829/// deterministically resolve to.
830///
831/// This is identity data only; it grants nothing. Authorization happens in
832/// [`ExecutionGate::authorize`], which is the only constructor of
833/// [`AuthorizedToolCall`].
834#[derive(Clone, Debug, PartialEq, Eq)]
835pub struct ResolvedToolCall {
836    wire_name: String,
837    runtime_name: String,
838    tool_id: ToolId,
839}
840
841impl ResolvedToolCall {
842    pub fn new(wire_name: &str, runtime_name: &str, tool_id: ToolId) -> Self {
843        Self {
844            wire_name: bounded_wire_name(wire_name),
845            runtime_name: runtime_name.to_string(),
846            tool_id,
847        }
848    }
849
850    /// The API/wire name as received (bounded for echo safety).
851    pub fn wire_name(&self) -> &str {
852        &self.wire_name
853    }
854
855    /// The exact runtime tool name the wire name resolves to.
856    pub fn runtime_name(&self) -> &str {
857        &self.runtime_name
858    }
859
860    /// The exact catalog identity of the resolved live tool.
861    pub fn tool_id(&self) -> &ToolId {
862        &self.tool_id
863    }
864}
865
866#[derive(Clone, Copy, Debug, PartialEq, Eq)]
867pub enum ActivationBasis {
868    Core,
869    Exact {
870        catalog_generation: CatalogGeneration,
871    },
872}
873
874/// A fully authorized tool call: exact resolved identity plus the acquired
875/// implementation. The implementation field is private and the ONLY
876/// constructor is [`ExecutionGate::authorize`], which acquires it strictly
877/// after every check passes — the safe path cannot reach an implementation
878/// without passing the gate.
879pub struct AuthorizedToolCall {
880    resolved: ResolvedToolCall,
881    implementation: Arc<dyn Tool>,
882    activation_basis: ActivationBasis,
883}
884
885impl AuthorizedToolCall {
886    pub fn resolved(&self) -> &ResolvedToolCall {
887        &self.resolved
888    }
889
890    pub fn wire_name(&self) -> &str {
891        self.resolved.wire_name()
892    }
893
894    pub fn runtime_name(&self) -> &str {
895        self.resolved.runtime_name()
896    }
897
898    pub fn tool_id(&self) -> &ToolId {
899        self.resolved.tool_id()
900    }
901
902    pub fn activation_basis(&self) -> ActivationBasis {
903        self.activation_basis
904    }
905
906    /// The authorized implementation handle.
907    pub fn implementation(&self) -> Arc<dyn Tool> {
908        Arc::clone(&self.implementation)
909    }
910}
911
912impl std::fmt::Debug for AuthorizedToolCall {
913    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
914        f.debug_struct("AuthorizedToolCall")
915            .field("resolved", &self.resolved)
916            .finish_non_exhaustive()
917    }
918}
919
920/// Typed execution-gate denial. Every variant is static, bounded, and
921/// metadata-only: no tool input, no schema bytes, no provider body content —
922/// only bounded identities and generation counters.
923#[derive(Clone, Debug, Error, PartialEq, Eq)]
924pub enum ToolAuthorizationError {
925    /// The wire name resolves to no live registered tool. Message shape kept
926    /// compatible with the legacy tool-loop denial; the echoed name is
927    /// bounded at resolution time.
928    #[error("Unknown tool: {wire_name}")]
929    UnknownTool { wire_name: String },
930    /// The live tool exists but the catalog holds no record for its
931    /// identity — the registry/catalog invariant is broken; fail closed.
932    #[error("Tool call denied: capability is not cataloged: {0}")]
933    NotCataloged(ToolId),
934    /// Known capability, but neither core for this session nor covered by an
935    /// exact activation grant (the forged deferred-call case).
936    #[error("Tool call denied: tool is not activated for this session: {0}")]
937    NotActivated(ToolId),
938    /// The capability's current schema digest differs from the digest the
939    /// session pinned at core-build/activation time — a changed tool is
940    /// never silently blessed.
941    #[error("Tool call denied: schema digest changed since authorization was pinned: {0}")]
942    SchemaDigestMismatch(ToolId),
943    /// Unverified/unknown provenance is denied by default, even if the
944    /// capability was (mis)configured as core, until an explicit later
945    /// policy exists.
946    #[error("Tool call denied: source provenance is unverified: {0}")]
947    UntrustedSource(ToolId),
948    /// Source/trust provenance failure: either the catalog record's source
949    /// and trust provenance disagree (an internally inconsistent record must
950    /// never authorize), or the record's CURRENT provenance differs from the
951    /// provenance the session pinned at core-build/activation time (a tool
952    /// removed and re-added under a different — even internally coherent —
953    /// provenance must never authorize).
954    #[error("Tool call denied: source/trust provenance is inconsistent or drifted from the session's pin: {0}")]
955    SourceProvenanceMismatch(ToolId),
956}
957
958/// The spec §7.1 execution gate: a pure, reusable authorization component
959/// evaluated immediately before tool implementation lookup/execution.
960///
961/// Order of checks (each fails typed, closed, without acquiring anything):
962/// 1. resolve wire name → exact live `ToolId` (deterministic reverse
963///    mapping; aliases cannot pick a different identity);
964/// 2. the identity must be cataloged;
965/// 3. the identity must be core (pinned digest intact) or hold an exact
966///    session activation grant covering (session, tool, digest) — see
967///    [`grant_covers_execution`]: catalog generation drift alone does NOT
968///    deny at execution time; only a real change to the called tool's
969///    record (digest, presence, provenance) does;
970/// 4. the record's CURRENT trust provenance must equal the provenance the
971///    session PINNED at core-build/activation time — this closes the
972///    coherent-imposter hole (same `ToolId`, schema-identical body,
973///    different but internally consistent source/provenance);
974/// 5. source trust is re-evaluated conservatively — see the honesty note
975///    on [`check_source_trust`]: this re-checks typed source/provenance
976///    consistency and denies Unknown/Unverified, but does NOT yet consult
977///    live manifest permission/revocation state (Task 20 integrates the
978///    permission/revocation policy);
979/// 6. side-effect/confirmation policy (interim: `Unclassified` is allowed
980///    only for verified-provenance capabilities — Task 24 adds real effect
981///    classes);
982/// 7. only then is the implementation acquired.
983///
984/// TOCTOU: callers must resolve, authorize, and acquire against ONE
985/// consistent registry borrow/lock guard — [`ExecutionGate::authorize_wire_call`]
986/// does exactly that for a `&ToolRegistry` held under the caller's read lock.
987pub struct ExecutionGate;
988
989impl ExecutionGate {
990    /// Resolve an incoming API/wire name against one registry snapshot.
991    pub fn resolve(
992        registry: &ToolRegistry,
993        wire_name: &str,
994    ) -> Result<ResolvedToolCall, ToolAuthorizationError> {
995        registry
996            .resolve_wire_call(wire_name)
997            .ok_or_else(|| ToolAuthorizationError::UnknownTool {
998                wire_name: bounded_wire_name(wire_name),
999            })
1000    }
1001
1002    /// Authorize a resolved identity against one catalog snapshot and one
1003    /// session tool set. On success — and only then — the implementation is
1004    /// acquired from the catalog record's factory and returned inside the
1005    /// typed [`AuthorizedToolCall`]. Failure acquires nothing and leaves the
1006    /// session set untouched (the gate never mutates it).
1007    ///
1008    /// SECURITY INVARIANT (per-tool digest + provenance validation): no
1009    /// tool executes unless its CURRENT catalog record — presence of the
1010    /// exact `ToolId`, schema digest, and source/trust provenance — exactly
1011    /// matches what the session pinned at core-build/activation time.
1012    /// Catalog generation inequality ALONE is no longer a denial reason at
1013    /// execution time: an unrelated background mutation (e.g. a plugin
1014    /// load mid-round) must not kill in-flight calls to schema-identical
1015    /// tools. Any REAL drift of the called tool's record — changed digest,
1016    /// removal, changed provenance (even to an internally coherent one) —
1017    /// still denies typed and closed.
1018    ///
1019    /// ACCEPTED RESIDUAL RISK: [`SchemaDigest`] hashes the schema JSON
1020    /// only, not the implementation factory. A record replaced in place
1021    /// with an identical schema AND identical pinned provenance but a
1022    /// different implementation passes this gate; the exposure is bounded
1023    /// by the deterministic round-top rebuild (runtime/stream.rs), which
1024    /// re-pins the set against the current catalog between provider rounds.
1025    pub fn authorize(
1026        catalog: &ToolCatalog,
1027        session: &SessionToolSet,
1028        resolved: ResolvedToolCall,
1029    ) -> Result<AuthorizedToolCall, ToolAuthorizationError> {
1030        let tool_id = resolved.tool_id().clone();
1031        let record = catalog
1032            .get(&tool_id)
1033            .ok_or_else(|| ToolAuthorizationError::NotCataloged(tool_id.clone()))?;
1034
1035        // Generation drift is observed (for logging) but NOT a wholesale
1036        // denial: the per-tool checks below are unconditional and judge the
1037        // called tool's CURRENT record against the session's pins. The
1038        // round-top rebuild (runtime/stream.rs) still uses `is_stale` to
1039        // refresh the set deterministically between rounds.
1040        let generation_drift = session.is_stale(catalog);
1041
1042        // Core status or exact activation grant, with pinned-digest
1043        // verification either way — unconditional, drift or not. The pinned
1044        // trust provenance is carried out of each branch for the
1045        // unconditional pinned-provenance check below.
1046        let activation_basis;
1047        let pinned_provenance: &TrustProvenance;
1048        if let Some(pin) = session.core_pin(&tool_id) {
1049            if pin.schema_digest() != record.schema_digest() {
1050                if generation_drift {
1051                    tracing::warn!(
1052                        tool = %tool_id,
1053                        set_generation = session.catalog_generation().value(),
1054                        catalog_generation = catalog.generation().value(),
1055                        "tool denied under catalog generation drift: pinned core schema digest \
1056                         no longer matches the current catalog record"
1057                    );
1058                }
1059                return Err(ToolAuthorizationError::SchemaDigestMismatch(tool_id));
1060            }
1061            pinned_provenance = pin.provenance();
1062            activation_basis = ActivationBasis::Core;
1063        } else if let Some(activated) = session.activation(&tool_id) {
1064            if activated.schema_digest() != record.schema_digest() {
1065                if generation_drift {
1066                    tracing::warn!(
1067                        tool = %tool_id,
1068                        set_generation = session.catalog_generation().value(),
1069                        catalog_generation = catalog.generation().value(),
1070                        "tool denied under catalog generation drift: activation grant schema \
1071                         digest no longer matches the current catalog record"
1072                    );
1073                }
1074                return Err(ToolAuthorizationError::SchemaDigestMismatch(tool_id));
1075            }
1076            // Execution-time grant re-check on the (session, tool, digest)
1077            // tuple — deliberately WITHOUT the generation term (see
1078            // `grant_covers_execution`). Grant ISSUANCE and activation stay
1079            // generation-strict (`validate_grant`, `covers()`).
1080            if !grant_covers_execution(
1081                activated.grant(),
1082                session.session().as_str(),
1083                &tool_id,
1084                record.schema_digest(),
1085            ) {
1086                return Err(ToolAuthorizationError::NotActivated(tool_id));
1087            }
1088            pinned_provenance = activated.provenance();
1089            activation_basis = ActivationBasis::Exact {
1090                catalog_generation: activated.catalog_generation(),
1091            };
1092        } else {
1093            return Err(ToolAuthorizationError::NotActivated(tool_id));
1094        }
1095
1096        // Unconditional pinned-provenance equality (BLOCKER fix): the
1097        // record's CURRENT trust provenance must equal the provenance the
1098        // session pinned at core-build/activation time. This — not the
1099        // self-consistency check below — is what catches the coherent
1100        // imposter: a tool removed and re-added under the same `ToolId`
1101        // with a schema-identical body but a different, internally
1102        // consistent source/provenance pair.
1103        if pinned_provenance != record.provenance() {
1104            if generation_drift {
1105                tracing::warn!(
1106                    tool = %tool_id,
1107                    set_generation = session.catalog_generation().value(),
1108                    catalog_generation = catalog.generation().value(),
1109                    "tool denied under catalog generation drift: current record provenance \
1110                     no longer matches the provenance the session pinned"
1111                );
1112            }
1113            return Err(ToolAuthorizationError::SourceProvenanceMismatch(tool_id));
1114        }
1115
1116        // Conservative source trust re-check immediately before
1117        // acquisition: typed source/provenance SELF-consistency plus the
1118        // deny-by-default for Unknown/Unverified (see check_source_trust) —
1119        // live manifest permission/revocation state is not consulted here
1120        // yet (Task 20). This complements (does not replace) the
1121        // pinned-provenance equality above.
1122        check_source_trust(record)?;
1123
1124        if generation_drift {
1125            tracing::warn!(
1126                tool = %tool_id,
1127                set_generation = session.catalog_generation().value(),
1128                catalog_generation = catalog.generation().value(),
1129                "tool call survived catalog generation drift: current record digest, presence \
1130                 and provenance exactly match the session's pins"
1131            );
1132        }
1133
1134        // Effect classes (Task 24) are execution-SCHEDULING policy, not
1135        // authorization policy: every class may execute once authorized —
1136        // the stream scheduler decides concurrency/ordering from the
1137        // recorded class. The exhaustive match keeps this decision loud if
1138        // a future class needs gate-level treatment.
1139        match record.effect() {
1140            ToolEffect::ReadOnly | ToolEffect::IdempotentWrite | ToolEffect::NonIdempotent => {}
1141        }
1142
1143        // Acquisition happens strictly after authorization succeeds.
1144        let implementation = record.implementation();
1145        Ok(AuthorizedToolCall {
1146            resolved,
1147            implementation,
1148            activation_basis,
1149        })
1150    }
1151
1152    /// Resolve + authorize + acquire against ONE registry borrow (one
1153    /// consistent snapshot under the caller's read lock — no TOCTOU between
1154    /// resolution and implementation acquisition).
1155    pub fn authorize_wire_call(
1156        registry: &ToolRegistry,
1157        session: &SessionToolSet,
1158        wire_name: &str,
1159    ) -> Result<AuthorizedToolCall, ToolAuthorizationError> {
1160        let resolved = Self::resolve(registry, wire_name)?;
1161        Self::authorize(registry.catalog(), session, resolved)
1162    }
1163}
1164
1165/// Execution-time grant coverage: (session, tool, digest) equality WITHOUT
1166/// the generation term of [`SessionActivationGrant::covers`]. Rationale: at
1167/// execution time the tool's identity and schema are already re-validated
1168/// against the CURRENT catalog record, so requiring the pinned generation to
1169/// equal the live one only makes unrelated catalog mutations (background
1170/// plugin loads) kill in-flight calls to schema-identical tools. `covers()`
1171/// itself (agent-core) stays exact-tuple and is still what grant ISSUANCE
1172/// and activation validation (`validate_grant`) enforce — this relaxation
1173/// applies ONLY to re-validation of an already-established authorization.
1174fn grant_covers_execution(
1175    grant: &SessionActivationGrant,
1176    session_id: &str,
1177    tool_id: &ToolId,
1178    schema_digest: &SchemaDigest,
1179) -> bool {
1180    grant.session_id() == session_id
1181        && grant.tool_id() == tool_id
1182        && grant.schema_digest() == schema_digest
1183}
1184
1185/// Conservative per-source trust policy — CONSISTENCY check, not a live
1186/// permission lookup: `BuiltinRuntime` provenance is valid only for
1187/// builtin-sourced records; extension/MCP/plugin provenance must match the
1188/// catalog source identity exactly; `Unverified` (and any unknown source)
1189/// is denied by default even if configured core.
1190///
1191/// Honesty note (Task 16): this validates the typed provenance recorded at
1192/// catalog time against the record's source identity. It does NOT consult
1193/// live manifest permission state or revocation — a capability whose
1194/// extension permissions were revoked after cataloging still passes this
1195/// check until the catalog entry is removed/rebuilt. Live
1196/// permission/revocation integration is Task 20.
1197/// Whether `activate_tools` could ever grant this record: the same source
1198/// trust check activation applies (verified provenance that matches its
1199/// declared source). Used to decide which dormant tools are advertised as
1200/// deferred definitions; it never authorizes anything by itself.
1201pub(crate) fn is_activatable_record(record: &CapabilityRecord) -> bool {
1202    check_source_trust(record).is_ok()
1203}
1204
1205fn check_source_trust(record: &CapabilityRecord) -> Result<(), ToolAuthorizationError> {
1206    let deny_untrusted = || ToolAuthorizationError::UntrustedSource(record.id().clone());
1207    let deny_mismatch = || ToolAuthorizationError::SourceProvenanceMismatch(record.id().clone());
1208    match (record.provenance(), record.source()) {
1209        (TrustProvenance::Unverified, _) => Err(deny_untrusted()),
1210        (_, CapabilitySource::Unknown { .. }) => Err(deny_untrusted()),
1211        (TrustProvenance::BuiltinRuntime, CapabilitySource::Builtin) => Ok(()),
1212        (
1213            TrustProvenance::ExtensionManifest { extension_id },
1214            CapabilitySource::Extension {
1215                extension_id: source_extension_id,
1216                ..
1217            },
1218        ) if extension_id == source_extension_id => Ok(()),
1219        (
1220            TrustProvenance::McpConfig { server_id },
1221            CapabilitySource::Mcp {
1222                server_id: source_server_id,
1223                ..
1224            },
1225        ) if server_id == source_server_id => Ok(()),
1226        (
1227            TrustProvenance::PluginConfig { plugin_id },
1228            CapabilitySource::Plugin {
1229                plugin_id: source_plugin_id,
1230                ..
1231            },
1232        ) if plugin_id == source_plugin_id => Ok(()),
1233        _ => Err(deny_mismatch()),
1234    }
1235}
1236
1237// ── Task 17: host grant issuance, typed confirmation authority, and the
1238//    retained shared session set (spec §7.2, §7.3, §7.7) ─────────────────────
1239
1240/// One retained per-stream-session handle: `activate_tools` mutates the SAME
1241/// set the `ExecutionGate` and the next provider round consume. Interior
1242/// critical sections are short and never held across `.await`.
1243pub type SharedSessionToolSet = Arc<std::sync::RwLock<SessionToolSet>>;
1244
1245/// The per-runtime slot that RETAINS a session's [`SharedSessionToolSet`]
1246/// across stream turns. Exact activations are session-scoped (the
1247/// `activate_tools` contract says so), so the set that holds them must outlive
1248/// a single provider turn: every turn of the same runtime tool session
1249/// reconciles and reuses the retained set instead of minting a fresh,
1250/// zero-activation one. Shared by `Runtime` clones exactly like
1251/// `host_tool_session`; empty until the first progressive turn.
1252pub type RetainedSessionToolSet = Arc<std::sync::Mutex<Option<SharedSessionToolSet>>>;
1253
1254/// Activations a runtime was handed from the same conversation's previous
1255/// runtime (daemon reload, unpark) and has not applied yet. Consumed by the
1256/// first progressive turn start (or projected read-only by a keep-warm
1257/// ping before it), against THAT moment's catalog — as late as possible, so
1258/// tools registered after the runtime was built are seen. Shared by
1259/// `Runtime` clones exactly like [`RetainedSessionToolSet`].
1260pub type PendingToolRestore = Arc<std::sync::Mutex<Option<Vec<CarriedActivation>>>>;
1261
1262/// Typed failure for deterministic bulk activation ([`SessionToolSet::activate_many`]).
1263/// Every variant fails the WHOLE batch with zero partial mutation.
1264#[derive(Clone, Debug, Error, PartialEq, Eq)]
1265pub enum BulkActivationError {
1266    #[error("duplicate tool id in one activation batch: {0}")]
1267    DuplicateRequest(ToolId),
1268    #[error("activation batch rejected: {0}")]
1269    Grant(#[from] ActivationError),
1270    #[error("activation batch rejected: source provenance is unverified: {0}")]
1271    UntrustedSource(ToolId),
1272    #[error("activation batch rejected: catalog source and trust provenance disagree: {0}")]
1273    SourceProvenanceMismatch(ToolId),
1274}
1275
1276/// Host-supplied activation authority for MODEL-INITIATED activation.
1277///
1278/// `ModelConfirmed` may be populated ONLY by host policy (explicit server
1279/// auto-approve or an interactive confirmation mechanism) — never from
1280/// model-authored JSON arguments. Explicit user-requested activation goes
1281/// through [`activate_exact_for_user`], a host Rust API that involves no
1282/// authority value at all (PR #63 ergonomics: a user asking for an exact
1283/// known tool needs no redundant prompt).
1284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1285pub enum ActivationAuthority {
1286    /// No confirmation authority: model-initiated activation is denied
1287    /// before any grant issuance or set/schema mutation.
1288    Unauthorized,
1289    /// Host confirmation policy satisfied for this session/turn.
1290    ModelConfirmed,
1291}
1292
1293/// Typed failure for host grant issuance. Issuance validates the exact
1294/// known `ToolId`, the current catalog generation/digest (pinned into the
1295/// grant), and source trust — before any grant exists.
1296#[derive(Clone, Debug, Error, PartialEq, Eq)]
1297pub enum GrantIssuanceError {
1298    #[error("cannot issue activation grant: tool is not in the catalog: {0}")]
1299    UnknownTool(ToolId),
1300    #[error("cannot issue activation grant: source provenance is unverified: {0}")]
1301    UntrustedSource(ToolId),
1302    #[error("cannot issue activation grant: catalog source and trust provenance disagree: {0}")]
1303    SourceProvenanceMismatch(ToolId),
1304    #[error("cannot issue activation grant: {0}")]
1305    Grant(#[from] agent_core::orchestration::capability::ActivationGrantError),
1306}
1307
1308/// Issue one exact session-scoped activation grant against the CURRENT
1309/// catalog state. Validates: the id is a known exact cataloged capability
1310/// and its source trust/provenance is consistent and verified. The grant
1311/// pins the current catalog generation and schema digest. Pure: no factory
1312/// invocation, no process start, no network, no set mutation.
1313pub fn issue_exact_grant(
1314    catalog: &ToolCatalog,
1315    session: &SessionId,
1316    tool_id: &ToolId,
1317) -> Result<SessionActivationGrant, GrantIssuanceError> {
1318    let record = catalog
1319        .get(tool_id)
1320        .ok_or_else(|| GrantIssuanceError::UnknownTool(tool_id.clone()))?;
1321    check_source_trust(record).map_err(|err| match err {
1322        ToolAuthorizationError::SourceProvenanceMismatch(id) => {
1323            GrantIssuanceError::SourceProvenanceMismatch(id)
1324        }
1325        _ => GrantIssuanceError::UntrustedSource(tool_id.clone()),
1326    })?;
1327    Ok(SessionActivationGrant::new(
1328        session.as_str(),
1329        tool_id.clone(),
1330        catalog.generation(),
1331        record.schema_digest().clone(),
1332    )?)
1333}
1334
1335/// Typed failure for the host activation entry points.
1336#[derive(Clone, Debug, Error, PartialEq, Eq)]
1337pub enum HostActivationError {
1338    /// Model-initiated activation without host confirmation authority.
1339    /// Denied BEFORE grant issuance and before any set/schema mutation.
1340    #[error(
1341        "tool activation requires host confirmation authority; \
1342         model-initiated activation was not confirmed"
1343    )]
1344    ConfirmationRequired,
1345    #[error(transparent)]
1346    Issuance(#[from] GrantIssuanceError),
1347    #[error(transparent)]
1348    Bulk(#[from] BulkActivationError),
1349}
1350
1351/// Explicit USER-REQUESTED exact activation (spec §7.3 / PR #63 ergonomics):
1352/// the host may authorize one exact known local identity without a
1353/// redundant confirmation prompt. This is a host Rust API — it is never
1354/// reachable from model-authored JSON and takes no confirmation boolean.
1355/// Grants are session-scoped and never persisted.
1356pub fn activate_exact_for_user(
1357    set: &mut SessionToolSet,
1358    catalog: &ToolCatalog,
1359    tool_id: &ToolId,
1360) -> Result<(), HostActivationError> {
1361    let grant = issue_exact_grant(catalog, set.session(), tool_id)?;
1362    set.activate_many(vec![grant], catalog)?;
1363    Ok(())
1364}
1365
1366/// MODEL-INITIATED exact activation: requires host-supplied
1367/// [`ActivationAuthority::ModelConfirmed`]. Without it, the request is
1368/// denied before any grant issuance, set mutation, or schema-generation
1369/// advance. All requested ids are validated and issued first; the batch
1370/// applies atomically in stable `ToolId` order with exactly one session
1371/// schema-generation advance. Grants are session-scoped, never persisted.
1372pub fn activate_model_initiated(
1373    authority: ActivationAuthority,
1374    set: &mut SessionToolSet,
1375    catalog: &ToolCatalog,
1376    tool_ids: &[ToolId],
1377) -> Result<usize, HostActivationError> {
1378    match authority {
1379        ActivationAuthority::ModelConfirmed => {}
1380        ActivationAuthority::Unauthorized => {
1381            return Err(HostActivationError::ConfirmationRequired);
1382        }
1383    }
1384    let session = set.session().clone();
1385    let mut grants = Vec::with_capacity(tool_ids.len());
1386    for tool_id in tool_ids {
1387        grants.push(issue_exact_grant(catalog, &session, tool_id)?);
1388    }
1389    Ok(set.activate_many(grants, catalog)?)
1390}
1391
1392/// Resolve the session tool set for the extension-provider route (Task 17,
1393/// closing the Task 16 policy divergence): when the runtime threads its
1394/// RETAINED per-stream set, the route consumes THAT set's current state —
1395/// including exact activations — at its pinned generation. The route must
1396/// never silently mint a fresh default-core set for the same round. Only
1397/// callers with no retained handle at all (internal/sync helpers) fall back
1398/// to a fresh default-core set with zero activations under a locally minted
1399/// session.
1400///
1401/// Generation drift is deliberately SURVIVED here (per-tool digest
1402/// validation fix): the retained set is served as-is even when the catalog
1403/// generation moved past its snapshot, because every actual execution still
1404/// passes through [`ExecutionGate::authorize`], which unconditionally
1405/// re-validates each called tool's current record (presence, digest,
1406/// provenance) against the session's pins. Denying the whole retained set
1407/// wholesale would let an unrelated background catalog mutation kill an
1408/// entire in-flight round of schema-identical tools. Infallible: neither
1409/// path can be denied anymore, so the return type is the plain set.
1410pub fn route_session_set(
1411    retained: Option<&SharedSessionToolSet>,
1412    catalog: &ToolCatalog,
1413    fallback_session: impl FnOnce() -> SessionId,
1414) -> SessionToolSet {
1415    match retained {
1416        Some(shared) => {
1417            let guard = shared
1418                .read()
1419                .unwrap_or_else(std::sync::PoisonError::into_inner);
1420            if guard.is_stale(catalog) {
1421                tracing::warn!(
1422                    set_generation = guard.catalog_generation().value(),
1423                    catalog_generation = catalog.generation().value(),
1424                    "serving retained session tool set across catalog generation drift; \
1425                     per-call ExecutionGate::authorize still protects execution"
1426                );
1427            }
1428            guard.clone()
1429        }
1430        None => SessionToolSet::default_core_for_catalog(fallback_session(), catalog),
1431    }
1432}