Skip to main content

bamboo_engine/
ticket_token_budget.rs

1//! Host-installed Native Work attribution. The canonical Ticket ledger is the
2//! only wallet; canonical Session ancestry and dispatch receipts supply scope.
3use std::sync::Arc;
4
5use bamboo_domain::{Session, SessionKind, Storage};
6use bamboo_tickets::{Authority, Error, Principal, Result, TicketService};
7
8use crate::ticket_runtime::{self, TicketDispatchBinding};
9
10mod provider;
11
12#[derive(Clone)]
13struct SessionStamp {
14    id: String,
15    created_at: chrono::DateTime<chrono::Utc>,
16    parent: Option<String>,
17    root: String,
18    kind: SessionKind,
19}
20
21impl SessionStamp {
22    fn from_session(session: &Session) -> Self {
23        Self {
24            id: session.id.clone(),
25            created_at: session.created_at,
26            parent: session.parent_session_id.clone(),
27            root: session.root_session_id.clone(),
28            kind: session.kind,
29        }
30    }
31    fn matches(&self, session: &Session) -> bool {
32        self.id == session.id
33            && self.created_at == session.created_at
34            && self.parent == session.parent_session_id
35            && self.root == session.root_session_id
36            && self.kind == session.kind
37    }
38}
39
40/// Constructible only inside the Native Host. This capability is not decoded
41/// from HTTP, prompts, tools, session display metadata or Worker payloads.
42pub struct NativeWorkTokenBudget {
43    service: Arc<TicketService>,
44    storage: Arc<dyn Storage>,
45    chain: Vec<SessionStamp>,
46    dispatch: Option<TicketDispatchBinding>,
47    binding: bamboo_tickets::ScopeBinding,
48}
49
50impl NativeWorkTokenBudget {
51    pub(crate) async fn capture(
52        service: Option<Arc<TicketService>>,
53        storage: Arc<dyn Storage>,
54        session: &Session,
55    ) -> Result<Option<Arc<Self>>> {
56        let Some(service) = service else {
57            if ticket_runtime::read_dispatch(session)?.is_some() {
58                return Err(Error::AuthorityUnavailable(
59                    "Ticket token authority unavailable".into(),
60                ));
61            }
62            return Ok(None);
63        };
64        let mut chain = Vec::<SessionStamp>::new();
65        let mut id = session.id.clone();
66        let mut dispatch = None;
67        for _ in 0..32 {
68            if chain.iter().any(|stamp| stamp.id == id) {
69                return Err(Error::ScopeDenied("cyclic Native ancestry".into()));
70            }
71            let canonical = storage
72                .load_runtime_control_plane(&id)
73                .await?
74                .ok_or_else(|| Error::ScopeDenied("Native ancestor missing".into()))?;
75            if chain.is_empty() && !SessionStamp::from_session(session).matches(&canonical) {
76                return Err(Error::ScopeDenied(
77                    "Native execution incarnation changed".into(),
78                ));
79            }
80            if dispatch.is_none() {
81                if let Some(actual) = ticket_runtime::read_dispatch(&canonical)? {
82                    if canonical.id != ticket_runtime::ticket_child_id(&actual.dispatch_key) {
83                        return Err(Error::ScopeDenied(
84                            "Native budget binding is outside its canonical Ticket child".into(),
85                        ));
86                    }
87                    dispatch = Some(actual);
88                }
89            }
90            chain.push(SessionStamp::from_session(&canonical));
91            if canonical.kind == SessionKind::Root {
92                let published = service.published()?.1;
93                if published.binding.supervisor_session_id != canonical.id {
94                    return if dispatch.is_some() {
95                        Err(Error::ScopeDenied(
96                            "Ticket budget scope differs from Native ancestry".into(),
97                        ))
98                    } else {
99                        Ok(None)
100                    };
101                }
102                // A mounted historical TicketService must not block an
103                // ordinary, unbudgeted Child after Root leaves orchestration
104                // mode. Keep the capability, so configuring limits during the
105                // run still revalidates fresh Root/Work authority per call.
106                let binding = if published.execution_budget.is_some() || dispatch.is_some() {
107                    ticket_runtime::verified_scope_binding_for_service(
108                        storage.as_ref(),
109                        &service,
110                        &canonical.id,
111                    )
112                    .await?
113                    .0
114                } else {
115                    published.binding.clone()
116                };
117                if binding != published.binding
118                    || dispatch.as_ref().is_some_and(|d| d.binding != binding)
119                {
120                    return Err(Error::ScopeDenied("Ticket budget binding changed".into()));
121                }
122                return Ok(Some(Arc::new(Self {
123                    service,
124                    storage,
125                    chain,
126                    dispatch,
127                    binding,
128                })));
129            }
130            if canonical.kind != SessionKind::Child {
131                return Err(Error::ScopeDenied(
132                    "Native ancestry must use canonical Child sessions".into(),
133                ));
134            }
135            id = canonical
136                .parent_session_id
137                .ok_or_else(|| Error::ScopeDenied("Native parent missing".into()))?;
138        }
139        Err(Error::ResourceBlocked(
140            "Native budget ancestry exceeds bound".into(),
141        ))
142    }
143
144    async fn authority(&self) -> Result<Authority> {
145        let (binding, _) = ticket_runtime::verified_scope_binding_for_service(
146            self.storage.as_ref(),
147            &self.service,
148            &self.binding.supervisor_session_id,
149        )
150        .await?;
151        if binding != self.binding {
152            return Err(Error::ScopeDenied(
153                "Native budget Supervisor changed".into(),
154            ));
155        }
156        for stamp in &self.chain {
157            let canonical = self
158                .storage
159                .load_runtime_control_plane(&stamp.id)
160                .await?
161                .ok_or_else(|| Error::ScopeDenied("Native budget ancestor missing".into()))?;
162            if !stamp.matches(&canonical) {
163                return Err(Error::ScopeDenied("Native budget ancestry changed".into()));
164            }
165            if let Some(actual) = ticket_runtime::read_dispatch(&canonical)? {
166                if self.dispatch.as_ref() != Some(&actual) {
167                    return Err(Error::ScopeDenied("Native budget dispatch changed".into()));
168                }
169            }
170        }
171        let dispatch = self.dispatch.as_ref().ok_or_else(|| {
172            Error::ResourceBlocked("configured scope requires an attributable Work dispatch".into())
173        })?;
174        ticket_runtime::require_dispatch_permission(&self.service, dispatch)?;
175        let snapshot = self.service.published()?.1;
176        let spec = &snapshot
177            .intents
178            .get(&dispatch.dispatch_key)
179            .ok_or_else(|| Error::ScopeDenied("Native budget dispatch missing".into()))?
180            .immutable_spec;
181        if !matches!(ticket_runtime::query_dispatch(self.storage.as_ref(), &self.service, &dispatch.dispatch_key, spec).await?,
182            ticket_runtime::DispatchObservation::Admitted { receipt } if dispatch.receipt.as_ref() == Some(&receipt))
183        {
184            return Err(Error::ScopeDenied(
185                "Native budget requires the original admitted attempt".into(),
186            ));
187        }
188        Ok(Authority::from_verified_host(binding, Principal::Runtime))
189    }
190}