Skip to main content

lean_ctx/core/execution_ledger/
host.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Trusted host authority for native Engine receipt publication.
4
5mod checkpoint;
6mod checkpoint_resume;
7mod checkpoint_transfer;
8mod context_decision;
9mod context_protocol;
10mod outcome;
11mod outcome_transport;
12mod source_recheck;
13mod source_replay;
14mod task_scope;
15pub use checkpoint::HostCheckpointRequest;
16
17/// G08: the Context Gateway's admission of the narrative of a document that
18/// another component produced and the Engine is about to write out — foreign
19/// content, admitted bound (unchanged, or refused whole) under `tool`, each
20/// text recorded in the call's open capture as `<document><field path>`.
21/// Only where protection is active; without it nothing changes. `Err` is
22/// content-free. Its one caller (`engine runtime invoke`) exists on Unix.
23#[cfg(unix)]
24pub fn admit_foreign_document_narrative(
25    document: &str,
26    value: &serde_json::Value,
27    tool: &'static str,
28) -> anyhow::Result<()> {
29    checkpoint_transfer::admit_foreign_document_narrative(document, value, tool)
30}
31use checkpoint_resume::CheckpointResumeTarget;
32pub use checkpoint_resume::HostCheckpointResumeRequest;
33use checkpoint_transfer::CheckpointImportAdmission;
34pub use checkpoint_transfer::{HostCheckpointImportRequest, HostCheckpointPackageRequest};
35pub use outcome::HostOutcomeRequest;
36
37use std::{
38    fs::File,
39    io::Read,
40    path::{Path, PathBuf},
41    time::{Duration, Instant},
42};
43
44use ed25519_dalek::SigningKey;
45use lean_ctx_protocol::{
46    AcceptanceState, ContextBalanceV1, DecisionRecordV1, EngineInvocationV1,
47    EngineObservationStatusV1, EngineObservationV1, EngineValueClassificationV1, ExecutionPlanV1,
48    ReceiptChainLinkV1, ReceiptEvidenceKindV1, ReceiptEvidenceRefV1, ReceiptOutcomeLinkV1,
49    ReceiptTerminalStatusV1, ReceiptValueClassificationV1, ReceiptValueV1, Sha256Digest,
50    SignatureStatus, TaskEnvelopeV1, UtcTimestamp,
51};
52use serde::Deserialize;
53use sha2::{Digest, Sha256};
54use zeroize::Zeroizing;
55
56use super::{
57    CanonicalReceiptRecordV1, ExecutionEvent, ExecutionLedgerStore, PublishedCanonicalReceipt,
58    ReceiptSignerAdmissionV1, record_canonical_engine_receipt,
59};
60use crate::core::execution_ledger::producer::validate_signer_admission;
61use crate::core::{
62    canonical::canonical_serialize,
63    context_kernel::autopilot::TaskAutopilotDecision,
64    engine_interface::{
65        MAX_TRANSPORT_BUDGET_TOKENS, persist_engine_artifact_content, verified_output_view,
66    },
67};
68
69const HOST_LIMIT: usize = 16 * 1024;
70
71#[derive(Deserialize)]
72#[serde(deny_unknown_fields)]
73struct HostSettings {
74    schema_version: u32,
75    #[serde(deserialize_with = "deserialize_secret")]
76    signing_key_hex: Zeroizing<String>,
77    signer: SignerAdmission,
78    ledger_path: PathBuf,
79    /// Separate operator grant; receipt authority never implies this purpose.
80    #[serde(default)]
81    allow_context_decision_signing: bool,
82    /// Independent operator grant for attested terminal outcomes, never tool arguments.
83    #[serde(default)]
84    allow_outcome_signing: bool,
85    /// Independent operator grant for canonical continuation signing.
86    #[serde(default)]
87    allow_checkpoint_signing: bool,
88    /// Optional, independently scoped grant to resume a local checkpoint.
89    #[serde(default)]
90    checkpoint_resume: Option<CheckpointResumeTarget>,
91    /// Independent operator grant to package a local checkpoint for transfer.
92    #[serde(default)]
93    allow_checkpoint_transfer_export: bool,
94    /// Optional, independently pinned foreign source admitted for manual import.
95    #[serde(default)]
96    checkpoint_import: Option<CheckpointImportAdmission>,
97    #[serde(default)]
98    checkpoint_continue: Option<checkpoint_transfer::CheckpointContinueAdmission>,
99    /// Receiver-owned source mapping, independent of foreign package metadata.
100    #[serde(default)]
101    checkpoint_source_files: Option<Vec<source_recheck::ReceivingFileBinding>>,
102    #[serde(default)]
103    checkpoint_source_providers: Option<Vec<source_recheck::ReceivingProviderBinding>>,
104    /// Optional operator binding for tasks admitted through this local host.
105    #[serde(default)]
106    task_scope: Option<task_scope::HostTaskScope>,
107}
108
109fn deserialize_secret<'de, D: serde::Deserializer<'de>>(
110    deserializer: D,
111) -> Result<Zeroizing<String>, D::Error> {
112    String::deserialize(deserializer).map(Zeroizing::new)
113}
114
115#[derive(Deserialize)]
116#[serde(deny_unknown_fields)]
117pub(super) struct SignerAdmission {
118    key_id: String,
119    public_key_digest: Sha256Digest,
120    admitted_at: UtcTimestamp,
121    expires_at: UtcTimestamp,
122    revoked_at: Option<UtcTimestamp>,
123}
124
125impl SignerAdmission {
126    pub(crate) fn snapshot(&self) -> ReceiptSignerAdmissionV1 {
127        ReceiptSignerAdmissionV1 {
128            key_id: self.key_id.clone(),
129            public_key_digest: self.public_key_digest.clone(),
130            admitted_at: self.admitted_at.clone(),
131            expires_at: self.expires_at.clone(),
132            revoked_at: self.revoked_at.clone(),
133        }
134    }
135}
136
137/// Private host signing authority loaded from an operator-owned configuration.
138///
139/// The signing key, admission snapshot, and ledger store never leave this
140/// authority.  In particular, this type intentionally does not implement
141/// `Debug`.
142pub struct HostReceiptAuthority {
143    signing_key: SigningKey,
144    signer_admission: ReceiptSignerAdmissionV1,
145    ledger: ExecutionLedgerStore,
146    allow_context_decision_signing: bool,
147    allow_outcome_signing: bool,
148    allow_checkpoint_signing: bool,
149    checkpoint_resume: Option<CheckpointResumeTarget>,
150    allow_checkpoint_transfer_export: bool,
151    checkpoint_import: Option<CheckpointImportAdmission>,
152    checkpoint_continue: Option<checkpoint_transfer::CheckpointContinueAdmission>,
153    checkpoint_source_files: Option<Vec<source_recheck::ReceivingFileBinding>>,
154    checkpoint_source_providers: Option<Vec<source_recheck::ReceivingProviderBinding>>,
155    task_scope: Option<task_scope::HostTaskScope>,
156}
157
158/// One admitted task attempt retaining its task lock through publication.
159pub struct HostReceiptAttempt {
160    task: TaskEnvelopeV1,
161    plan: ExecutionPlanV1,
162    context_decision: Option<DecisionRecordV1>,
163    context: Option<TaskAutopilotDecision>,
164    _task_lock: File,
165}
166
167impl HostReceiptAttempt {
168    pub fn context_decision(&self) -> Option<&DecisionRecordV1> {
169        self.context_decision.as_ref()
170    }
171}
172
173impl HostReceiptAuthority {
174    /// Load the strict, bounded host settings document from an existing reader.
175    pub fn from_reader(input: &mut dyn Read) -> Result<Self, &'static str> {
176        Self::from_reader_scoped(input, None)
177    }
178
179    /// Constrain an operator-selected ledger to the caller's trusted state root.
180    /// Uses the existing descriptor-pinned ledger store; no second store authority.
181    pub fn from_reader_scoped(
182        input: &mut dyn Read,
183        ledger_root: Option<&Path>,
184    ) -> Result<Self, &'static str> {
185        let bytes = read_bounded(input, HOST_LIMIT, "invalid_host_configuration")?;
186        let settings: HostSettings =
187            serde_json::from_slice(&bytes).map_err(|_| "invalid_host_configuration")?;
188        let key = parse_key(settings.signing_key_hex.as_str())?;
189        let admission = settings.signer.snapshot();
190        validate_settings(&settings, &admission)?;
191        validate_admission(&admission, &key)?;
192        let ledger = match ledger_root {
193            Some(root) => {
194                let relative = settings
195                    .ledger_path
196                    .strip_prefix(root)
197                    .map_err(|_| "host_ledger_outside_root")?;
198                ExecutionLedgerStore::new_verified(root, relative)
199                    .map_err(|_| "invalid_host_ledger_root")?
200            }
201            None => ExecutionLedgerStore::new(settings.ledger_path),
202        };
203        Ok(Self {
204            signing_key: key,
205            signer_admission: admission,
206            ledger,
207            allow_context_decision_signing: settings.allow_context_decision_signing,
208            allow_outcome_signing: settings.allow_outcome_signing,
209            allow_checkpoint_signing: settings.allow_checkpoint_signing,
210            checkpoint_resume: settings.checkpoint_resume,
211            allow_checkpoint_transfer_export: settings.allow_checkpoint_transfer_export,
212            checkpoint_import: settings.checkpoint_import,
213            checkpoint_continue: settings.checkpoint_continue,
214            checkpoint_source_files: settings.checkpoint_source_files,
215            checkpoint_source_providers: settings.checkpoint_source_providers,
216            task_scope: settings.task_scope,
217        })
218    }
219
220    /// Revalidate the trusted signer against the host's current UTC clock.
221    pub fn validate_current(&self) -> Result<(), &'static str> {
222        if !valid_key_id(&self.signer_admission.key_id) {
223            return Err("invalid_host_configuration");
224        }
225        validate_admission(&self.signer_admission, &self.signing_key)
226    }
227
228    /// Require the independent operator grant before starting a source-bound attempt.
229    pub fn require_context_decision_signing(&self) -> Result<(), &'static str> {
230        self.validate_current()?;
231        if !self.allow_context_decision_signing {
232            return Err("host_context_decision_not_authorized");
233        }
234        Ok(())
235    }
236
237    /// Admit one task, persist intent, and retain its exclusive task lock.
238    pub fn begin(
239        &self,
240        task: &TaskEnvelopeV1,
241        plan: &ExecutionPlanV1,
242    ) -> Result<HostReceiptAttempt, &'static str> {
243        self.begin_with_context(task, plan, None)
244    }
245
246    /// Capture the actual immutable planning handoff before Engine execution.
247    pub fn begin_with_context(
248        &self,
249        task: &TaskEnvelopeV1,
250        plan: &ExecutionPlanV1,
251        context: Option<&TaskAutopilotDecision>,
252    ) -> Result<HostReceiptAttempt, &'static str> {
253        self.validate_current()?;
254        task.validate().map_err(|_| "invalid_host_task")?;
255        plan.validate().map_err(|_| "invalid_host_plan")?;
256        if task.task_id != plan.task_id
257            || plan
258                .executor_agent_id
259                .as_ref()
260                .is_some_and(|agent| agent != &task.agent_id)
261            || plan
262                .context_token_limit()
263                .is_some_and(|limit| limit == 0 || limit > MAX_TRANSPORT_BUDGET_TOKENS)
264        {
265            return Err("invalid_host_plan");
266        }
267
268        let task_lock = task_lock(task)?;
269        if !self
270            .ledger
271            .by_task_verified(task.task_id.as_str())
272            .map_err(|_| "host_ledger_unavailable")?
273            .is_empty()
274        {
275            return Err("host_task_already_recorded");
276        }
277        let context_decision = if self.allow_context_decision_signing {
278            Some(context_decision::capture(
279                self,
280                task,
281                plan,
282                context.ok_or("host_context_decision_missing")?,
283            )?)
284        } else {
285            None
286        };
287        record_attempt(task, &self.ledger)?;
288        Ok(HostReceiptAttempt {
289            task: task.clone(),
290            plan: plan.clone(),
291            context_decision,
292            context: self
293                .allow_context_decision_signing
294                .then(|| context.cloned())
295                .flatten(),
296            _task_lock: task_lock,
297        })
298    }
299
300    /// Verify and publish the exact delivered view as one canonical receipt.
301    pub fn publish(
302        &self,
303        attempt: &HostReceiptAttempt,
304        invocation: &EngineInvocationV1,
305        observation: &EngineObservationV1,
306        delivered: &str,
307    ) -> Result<PublishedCanonicalReceipt, &'static str> {
308        self.validate_current()?;
309        if attempt.task.task_id != attempt.plan.task_id {
310            return Err("invalid_host_plan");
311        }
312        if observation.status != EngineObservationStatusV1::Succeeded {
313            return Err("host_engine_evidence_unavailable");
314        }
315
316        let view = verified_output_view(invocation, observation)
317            .map_err(|_| "host_engine_evidence_unavailable")?;
318        if view.text != delivered {
319            return Err("host_engine_evidence_unavailable");
320        }
321
322        let measurement = |name: &str| -> Result<u64, &'static str> {
323            observation
324                .measurements
325                .iter()
326                .find(|entry| {
327                    entry.name == name
328                        && entry.unit == "token"
329                        && entry.classification == EngineValueClassificationV1::Measured
330                })
331                .and_then(|entry| entry.value)
332                .ok_or("host_engine_measurement_unavailable")
333        };
334        let original_tokens = measurement("input_tokens")?;
335        let delivered_tokens = measurement("output_tokens")?;
336        if attempt
337            .plan
338            .context_token_limit()
339            .is_some_and(|limit| delivered_tokens > limit)
340        {
341            return Err("host_context_budget_exceeded");
342        }
343
344        let receipt_link = observation
345            .receipt_link
346            .as_ref()
347            .ok_or("host_engine_evidence_unavailable")?;
348        let observation_digest = digest(&canonical_serialize(observation))?;
349        let values = [
350            ("input_tokens", original_tokens),
351            ("output_tokens", delivered_tokens),
352        ]
353        .into_iter()
354        .map(|(name, value)| ReceiptValueV1 {
355            name: name.into(),
356            unit: "token".into(),
357            classification: ReceiptValueClassificationV1::Measured,
358            value: Some(value),
359            evidence_digests: vec![observation_digest.clone()],
360            formula_digest: None,
361            price_table_digest: None,
362            reconciliation_digest: None,
363        })
364        .chain([ReceiptValueV1 {
365            name: "provider_cost_micros".into(),
366            unit: "microusd".into(),
367            classification: ReceiptValueClassificationV1::Unavailable,
368            value: None,
369            evidence_digests: vec![],
370            formula_digest: None,
371            price_table_digest: None,
372            reconciliation_digest: None,
373        }])
374        .collect();
375        let chain_id = digest(&canonical_serialize(&(
376            "host-receipt-chain-v1",
377            &attempt.task,
378            &attempt.plan,
379        )))?;
380        let mut evidence_refs = vec![ReceiptEvidenceRefV1 {
381            kind: ReceiptEvidenceKindV1::Measurement,
382            uri: lean_ctx_protocol::ProtocolReference::new(format!(
383                "artifact://engine/receipts/{}",
384                receipt_link.receipt_digest.hex(),
385            ))
386            .map_err(|_| "host_engine_evidence_unavailable")?,
387            digest: receipt_link.receipt_digest.clone(),
388            media_type: "application/json".into(),
389            signature_status: SignatureStatus::NotSigned,
390        }];
391        if let Some(decision) = &attempt.context_decision {
392            // Capture already persisted and signed this exact planning record.
393            // Bind its address so outcome consumers need no artifact-store scan.
394            let decision_digest = digest(&canonical_serialize(decision))?;
395            evidence_refs.push(ReceiptEvidenceRefV1 {
396                kind: ReceiptEvidenceKindV1::Runtime,
397                uri: lean_ctx_protocol::ProtocolReference::new(format!(
398                    "artifact://execution/evidence/{}",
399                    decision_digest.hex(),
400                ))
401                .map_err(|_| "host_context_decision_invalid")?,
402                digest: decision_digest,
403                media_type: "application/json".into(),
404                signature_status: SignatureStatus::Unverified,
405            });
406        }
407        record_canonical_engine_receipt(
408            &attempt.task,
409            &attempt.plan,
410            invocation,
411            observation,
412            CanonicalReceiptRecordV1 {
413                context_balance: ContextBalanceV1 {
414                    original_tokens,
415                    materialized_tokens: delivered_tokens,
416                    delivered_tokens,
417                    provider_billed_tokens: 0,
418                },
419                status: ReceiptTerminalStatusV1::Succeeded,
420                values,
421                outcome: ReceiptOutcomeLinkV1 {
422                    state: AcceptanceState::Unknown,
423                    outcome_id: None,
424                    outcome_ref: None,
425                    acceptance_evidence_digest: None,
426                },
427                evidence_refs,
428                chain: ReceiptChainLinkV1 {
429                    chain_id: chain_id.as_str().into(),
430                    sequence_number: 1,
431                    previous_receipt_id: None,
432                    previous_signature_digest: None,
433                },
434                issued_at: now()?,
435                signer_admission: self.signer_admission.clone(),
436            },
437            &self.signing_key,
438            &self.ledger,
439        )
440        .map_err(|_| "host_receipt_publication_failed")
441    }
442}
443
444fn validate_settings(
445    settings: &HostSettings,
446    admission: &ReceiptSignerAdmissionV1,
447) -> Result<(), &'static str> {
448    if settings.schema_version != 1
449        || !settings.ledger_path.is_absolute()
450        || !valid_key_id(&admission.key_id)
451    {
452        return Err("invalid_host_configuration");
453    }
454    Ok(())
455}
456
457fn validate_admission(
458    admission: &ReceiptSignerAdmissionV1,
459    key: &SigningKey,
460) -> Result<(), &'static str> {
461    validate_signer_admission(admission, &key.verifying_key(), &now()?)
462        .map_err(|_| "host_signer_not_admitted")
463}
464
465fn valid_key_id(value: &str) -> bool {
466    !value.is_empty()
467        && value.len() <= 128
468        && value.as_bytes()[0].is_ascii_alphanumeric()
469        && value
470            .bytes()
471            .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
472        && !value.starts_with("base64:")
473        && !value.starts_with("hex:")
474}
475
476fn record_attempt(
477    task: &TaskEnvelopeV1,
478    ledger: &ExecutionLedgerStore,
479) -> Result<(), &'static str> {
480    let bytes = canonical_serialize(task);
481    let task_ref = digest(&bytes)?;
482    drop(
483        persist_engine_artifact_content("execution/evidence", task_ref.hex(), "json", &bytes)
484            .map_err(|_| "host_task_evidence_unavailable")?,
485    );
486    if !ledger
487        .append_if_new(ExecutionEvent::TaskStarted {
488            task_id: task.task_id.as_str().into(),
489            trace_id: task.trace_id.as_str().into(),
490            envelope_ref: task_ref.as_str().into(),
491            timestamp: task.created_at.as_str().into(),
492            sequence_number: 0,
493            prev_hash: String::new(),
494        })
495        .map_err(|_| "host_ledger_unavailable")?
496    {
497        return Err("host_task_already_recorded");
498    }
499    Ok(())
500}
501
502#[cfg(not(windows))]
503fn open_task_lock_file(identity: &str, bytes: &[u8]) -> Result<File, String> {
504    persist_engine_artifact_content("execution/host-locks", identity, "json", bytes)
505}
506
507#[cfg(not(windows))]
508fn publish_task_identity(_identity: &str, _bytes: &[u8]) -> Result<(), String> {
509    Ok(())
510}
511
512/// Windows byte-range locks are mandatory: a lock on the content artifact
513/// would stop the next opener from verifying its bytes, so the lock lives in
514/// a sidecar beside it.
515#[cfg(windows)]
516fn open_task_lock_file(identity: &str, _bytes: &[u8]) -> Result<File, String> {
517    crate::core::engine_interface::open_engine_artifact_lock("execution/host-locks", identity)
518}
519
520/// Publish the task identity artifact only while holding the sidecar lock, so
521/// concurrent callers never race to publish the same leaf (a Windows rename
522/// over a leaf another caller is verifying fails with a sharing violation).
523#[cfg(windows)]
524fn publish_task_identity(identity: &str, bytes: &[u8]) -> Result<(), String> {
525    persist_engine_artifact_content("execution/host-locks", identity, "json", bytes).map(drop)
526}
527
528fn task_lock_identity(task: &TaskEnvelopeV1) -> Result<(String, Vec<u8>), &'static str> {
529    let bytes = canonical_serialize(&(
530        "host-receipt-task-v1",
531        &task.project_id,
532        &task.tenant_id,
533        &task.task_id,
534    ));
535    let identity = digest(&bytes)?;
536    Ok((identity.hex().to_owned(), bytes))
537}
538
539/// The stable code hides the artifact-store cause; test runs print it (#1294).
540fn task_lock_unavailable(step: &str, cause: &str) -> &'static str {
541    #[cfg(test)]
542    eprintln!("host task lock {step} failed: {cause}");
543    #[cfg(not(test))]
544    let _ = (step, cause);
545    "host_task_lock_unavailable"
546}
547
548fn task_lock(task: &TaskEnvelopeV1) -> Result<File, &'static str> {
549    let (identity, bytes) = task_lock_identity(task)?;
550    let file = open_task_lock_file(&identity, &bytes)
551        .map_err(|error| task_lock_unavailable("open", &error))?;
552    let started = Instant::now();
553    loop {
554        match fs2::FileExt::try_lock_exclusive(&file) {
555            Ok(()) => {
556                publish_task_identity(&identity, &bytes)
557                    .map_err(|error| task_lock_unavailable("publish", &error))?;
558                return Ok(file);
559            }
560            Err(error)
561                if crate::core::file_lock::is_contended(&error)
562                    && started.elapsed() < Duration::from_secs(2) =>
563            {
564                std::thread::sleep(Duration::from_millis(10));
565            }
566            Err(_) => return Err("host_task_busy"),
567        }
568    }
569}
570
571fn read_bounded(
572    input: &mut dyn Read,
573    limit: usize,
574    code: &'static str,
575) -> Result<Zeroizing<Vec<u8>>, &'static str> {
576    let mut bytes = Zeroizing::new(Vec::new());
577    input
578        .take(limit as u64 + 1)
579        .read_to_end(&mut bytes)
580        .map_err(|_| code)?;
581    if bytes.len() > limit {
582        return Err(code);
583    }
584    Ok(bytes)
585}
586
587pub fn parse_key(value: &str) -> Result<SigningKey, &'static str> {
588    if value.len() != 64
589        || !value
590            .bytes()
591            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
592    {
593        return Err("invalid_signing_key");
594    }
595    let mut bytes = Zeroizing::new([0_u8; 32]);
596    for (index, byte) in bytes.iter_mut().enumerate() {
597        *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
598            .map_err(|_| "invalid_signing_key")?;
599    }
600    Ok(SigningKey::from_bytes(&bytes))
601}
602
603fn now() -> Result<UtcTimestamp, &'static str> {
604    UtcTimestamp::new(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
605        .map_err(|_| "host_clock_unavailable")
606}
607
608pub fn digest(bytes: &[u8]) -> Result<Sha256Digest, &'static str> {
609    Sha256Digest::new(format!(
610        "sha256:{}",
611        crate::core::agent_identity::hex_encode(&Sha256::digest(bytes))
612    ))
613    .map_err(|_| "host_digest_unavailable")
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn task_lock_is_bounded_and_reusable_after_owner_exit() {
622        let _data = crate::core::data_dir::isolated_data_dir();
623        let task: TaskEnvelopeV1 = serde_json::from_value(serde_json::json!({
624            "schema_version":1,"task_id":"host-lock-task","trace_id":"host-lock-trace",
625            "project_id":"host-project","session_id":"host-session","agent_id":"host-agent",
626            "complexity":"unknown","created_at":"2026-01-01T00:00:00Z"
627        }))
628        .unwrap();
629        let owner = task_lock(&task).unwrap();
630        assert_eq!(task_lock(&task).err(), Some("host_task_busy"));
631        drop(owner);
632        assert!(task_lock(&task).is_ok());
633    }
634}