diff --git a/Cargo.lock b/Cargo.lock index 13ef691f5e7..0ffd80892b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4140,6 +4140,8 @@ dependencies = [ "ironclaw_run_state", "ironclaw_safety", "ironclaw_trust", + "ironclaw_turns", + "serde", "serde_json", "thiserror 2.0.18", "tokio", diff --git a/crates/AGENTS.md b/crates/AGENTS.md index abb1cc9d8f2..c4be43110fc 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -125,7 +125,7 @@ Boundary rule: if you need an upstream crate in a low-level crate, stop and chec | `ironclaw_conversations` | `ironclaw_conversations/AGENTS.md`, `ironclaw_conversations/CLAUDE.md` | Conversation binding, session thread contracts, inbound/state store, libSQL/Postgres conversation persistence. | Capability runtime internals or UI transport. | | `ironclaw_agent_loop` | `ironclaw_agent_loop/AGENTS.md`, `ironclaw_agent_loop/CLAUDE.md` | Agent-loop framework state, planner/executor, strategy/family contracts, test support. | Product adapters, transport, concrete provider auth. | | `ironclaw_loop_host` | `ironclaw_loop_host/AGENTS.md`, `ironclaw_loop_host/CLAUDE.md` | Loop host support services: capability/input ports, allow sets, input queue, identity/skill context, cancellation. | Owning core loop strategy or runtime lane execution. | -| `ironclaw_capabilities` | `ironclaw_capabilities/AGENTS.md`, `ironclaw_capabilities/CLAUDE.md` | Caller-facing `CapabilityHost` invoke/resume/spawn workflow, obligation seams, conformance helpers. | Process lifecycle APIs, direct concrete runtime dependencies. | +| `ironclaw_capabilities` | `ironclaw_capabilities/AGENTS.md`, `ironclaw_capabilities/CLAUDE.md` | Caller-facing `CapabilityHost` invoke/resume/spawn workflow, obligation seams, conformance helpers, and the host-private `ReplayPayloadStore` (raw gate/auth resume replay payload, never model-visible). | Process lifecycle APIs, direct concrete runtime dependencies. | | `ironclaw_engine` | `ironclaw_engine/AGENTS.md`, `ironclaw_engine/CLAUDE.md`, `ironclaw_engine/MONTY.md` | **v1-only (legacy — retires with the monolith).** The root crate's engine v2 (thread/capability/CodeAct: runtime manager, executor, gates, leases). Reborn crates are boundary-test-forbidden from importing it. | **Any new Reborn behavior.** Maintenance of existing v1 behavior only. | ### Product, adapters, Reborn binary diff --git a/crates/ironclaw_capabilities/AGENTS.md b/crates/ironclaw_capabilities/AGENTS.md index d9b29b0dde2..7df280538d1 100644 --- a/crates/ironclaw_capabilities/AGENTS.md +++ b/crates/ironclaw_capabilities/AGENTS.md @@ -16,6 +16,7 @@ - `CapabilityHost` (`host`) and the invoke/resume/spawn requests/results: `CapabilityInvocationRequest`/`CapabilityInvocationResult`, `CapabilityResumeRequest`, `CapabilitySpawnRequest`/`CapabilitySpawnResult` (`requests`); `CapabilityInvocationError`/`ResumeContextMismatchKind` (`error`). - The obligation seam (`obligations`): `CapabilityObligationHandler`, `CapabilityObligationRequest`/`CapabilityObligationOutcome`, abort/completion requests, `CapabilityObligationPhase`/`CapabilityObligationFailureKind`/`CapabilityObligationError`. - Capability-profile conformance evaluation (`conformance`): `CapabilityProfileClaim`/`CapabilityProfileClaimedOperation`, the conformance report/findings, and `evaluate_profile_conformance`. +- The host-private replay-payload store (`replay_payload`): `ReplayPayload`, the `ReplayPayloadStore` port, `FilesystemReplayPayloadStore`, and `ReplayPayloadStoreError`. Persists the raw replay payload a gate/auth resume re-dispatches from, keyed by `InvocationId`, behind a `ScopedFilesystem` CAS lane. Never model-visible (no `SafeSummary`) — see `CLAUDE.md`. - Crate-local public API, tests, and fixtures needed to prove that ownership. ## Do Not Move In Here diff --git a/crates/ironclaw_capabilities/CLAUDE.md b/crates/ironclaw_capabilities/CLAUDE.md index 3f28817d435..747f278954f 100644 --- a/crates/ironclaw_capabilities/CLAUDE.md +++ b/crates/ironclaw_capabilities/CLAUDE.md @@ -8,3 +8,4 @@ - Approval resume must validate and claim the matching fingerprinted lease before dispatch. - Authorization denial or unsupported/failed obligations must fail before runtime dispatch, process start, or approval lease claim. - Keep obligation handling behind a seam; built-in obligation implementations belong in later host-runtime/obligation slices. +- The `ReplayPayloadStore` (`replay_payload`) persists the **host-private** raw replay payload (tool `input`, `estimate`, prior-approval identity, input ref, correlation id) a gate/auth resume re-dispatches from, keyed by `InvocationId`. It is the opposite of a model-visible `GateRecord`: it carries no `SafeSummary` and must never reach the model, an event, an error, a snapshot, or a log — the record exists only for host-side re-dispatch. It lives here (not `ironclaw_run_state`, whose charter forbids raw replay input, nor `ironclaw_turns`, whose charter forbids raw tool input in turn state/events) because capabilities owns the invoke/resume workflow this payload serves. The `ironclaw_filesystem` / `ironclaw_turns` dependencies exist for this store: it persists behind a `ScopedFilesystem` over the shared `cas_update` lane (fail-closed on non-CAS backends) and embeds the resume-payload field types owned by `ironclaw_turns` (`CapabilityInputRef`, `AuthResumeApprovalIdentity`) rather than re-typing them. Write-once; no removal method until an explicit retention contract adds one. diff --git a/crates/ironclaw_capabilities/Cargo.toml b/crates/ironclaw_capabilities/Cargo.toml index 236ffc8e016..44f6d561e53 100644 --- a/crates/ironclaw_capabilities/Cargo.toml +++ b/crates/ironclaw_capabilities/Cargo.toml @@ -14,11 +14,19 @@ async-trait = "0.1" chrono = "0.4" ironclaw_authorization = { path = "../ironclaw_authorization" } ironclaw_extensions = { path = "../ironclaw_extensions" } +# Storage substrate: the host-private ReplayPayloadStore persists behind a +# ScopedFilesystem over the shared CAS lane (mirrors run_state's Filesystem*Store). +ironclaw_filesystem = { path = "../ironclaw_filesystem" } ironclaw_host_api = { path = "../ironclaw_host_api" } ironclaw_processes = { path = "../ironclaw_processes" } ironclaw_run_state = { path = "../ironclaw_run_state" } ironclaw_safety = { path = "../ironclaw_safety" } ironclaw_trust = { path = "../ironclaw_trust" } +# Resume-payload vocabulary owner: ReplayPayload embeds CapabilityInputRef and +# AuthResumeApprovalIdentity from turns (the CapabilityApprovalResume/AuthResume +# field types) rather than re-typing them. +ironclaw_turns = { path = "../ironclaw_turns" } +serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tracing = "0.1" @@ -33,6 +41,5 @@ ironclaw_processes = { path = "../ironclaw_processes", features = ["test-support ironclaw_run_state = { path = "../ironclaw_run_state", features = ["test-support"] } ironclaw_dispatcher = { path = "../ironclaw_dispatcher" } ironclaw_events = { path = "../ironclaw_events" } -ironclaw_filesystem = { path = "../ironclaw_filesystem" } ironclaw_resources = { path = "../ironclaw_resources" } tokio = { version = "1", features = ["macros", "rt"] } diff --git a/crates/ironclaw_capabilities/src/host.rs b/crates/ironclaw_capabilities/src/host.rs index ed5a0f605a1..89931f5044a 100644 --- a/crates/ironclaw_capabilities/src/host.rs +++ b/crates/ironclaw_capabilities/src/host.rs @@ -7,9 +7,9 @@ use ironclaw_host_api::{ ActivityId, AuthorizeResult, Authorized, Blocked, CapabilityAuthorizer, CapabilityDescriptor, CapabilityDispatchRequest, CapabilityDispatchResult, CapabilityDispatcher, CapabilityGrantId, CapabilityId, Decision, DenyReason, DenyRef, DispatchError, ExecutionContext, GateRef, - Invocation, InvocationFingerprint, InvocationId, InvocationOrigin, Obligation, ProcessId, - ProductKind, ResourceEstimate, ResourceReservation, ResourceReservationId, ResourceScope, - RuntimeLane, + GateWaypoint, Invocation, InvocationFingerprint, InvocationId, InvocationOrigin, Obligation, + ProcessId, ProductKind, ResourceEstimate, ResourceReservation, ResourceReservationId, + ResourceScope, RuntimeLane, }; use ironclaw_processes::{ProcessManager, ProcessStart}; use ironclaw_run_state::{ @@ -729,8 +729,8 @@ where } } Ok(AuthorizeFold::Blocked { - result: AuthorizeResult::Blocked(Blocked::Approval(GateRef::from_uuid( - approval_request_id.as_uuid(), + result: AuthorizeResult::Blocked(Blocked::Approval(GateWaypoint::new( + GateRef::from_uuid(approval_request_id.as_uuid()), ))), }) } @@ -2035,8 +2035,8 @@ where } } Ok(AuthorizeFold::Blocked { - result: AuthorizeResult::Blocked(Blocked::Approval(GateRef::from_uuid( - approval_request_id.as_uuid(), + result: AuthorizeResult::Blocked(Blocked::Approval(GateWaypoint::new( + GateRef::from_uuid(approval_request_id.as_uuid()), ))), }) } @@ -2154,7 +2154,9 @@ where // `AuthorizationRequiresApproval` with no persisted gate, so the // forward-looking Blocked witness carries a fresh correlation id. Ok(AuthorizeFold::Blocked { - result: AuthorizeResult::Blocked(Blocked::Approval(GateRef::new())), + result: AuthorizeResult::Blocked(Blocked::Approval(GateWaypoint::new( + GateRef::new(), + ))), }) } } diff --git a/crates/ironclaw_capabilities/src/lib.rs b/crates/ironclaw_capabilities/src/lib.rs index 1d375d123d2..1862d065230 100644 --- a/crates/ironclaw_capabilities/src/lib.rs +++ b/crates/ironclaw_capabilities/src/lib.rs @@ -10,6 +10,7 @@ mod error; mod helpers; mod host; mod obligations; +mod replay_payload; mod requests; pub use conformance::{ @@ -24,6 +25,9 @@ pub use obligations::{ CapabilityObligationError, CapabilityObligationFailureKind, CapabilityObligationHandler, CapabilityObligationOutcome, CapabilityObligationPhase, CapabilityObligationRequest, }; +pub use replay_payload::{ + FilesystemReplayPayloadStore, ReplayPayload, ReplayPayloadStore, ReplayPayloadStoreError, +}; pub use requests::{ CapabilityAuthResumeRequest, CapabilityInvocationRequest, CapabilityInvocationResult, CapabilityResumeRequest, CapabilitySpawnRequest, CapabilitySpawnResult, diff --git a/crates/ironclaw_capabilities/src/replay_payload.rs b/crates/ironclaw_capabilities/src/replay_payload.rs new file mode 100644 index 00000000000..fcf96e6a491 --- /dev/null +++ b/crates/ironclaw_capabilities/src/replay_payload.rs @@ -0,0 +1,350 @@ +//! Host-private replay-payload persistence for gate/auth resume (§5.3). +//! +//! When a capability invocation blocks at an approval or auth gate, the raw +//! replay payload needed to re-dispatch on a **later** resume turn — the tool +//! `input` JSON, its [`ResourceEstimate`], the prior-approval identity, the +//! input ref, and the correlation id — currently rides *in-band* through the +//! untrusted loop on [`CapabilityApprovalResume`] / +//! [`CapabilityAuthResume`](ironclaw_turns::run_profile::CapabilityAuthResume) +//! and is stashed in the loop's own serialized checkpoint. The +//! capability-result collapse (arch-simplification §5.3) makes the loop-facing +//! `Resolution` carry only an opaque resume token (equal to the +//! [`InvocationId`]), so the **host** must persist the replay payload itself and +//! reconstitute it on resume. +//! +//! [`ReplayPayload`] is therefore the exact opposite of a +//! [`GateRecord`](ironclaw_host_api::GateRecord): a `GateRecord` is the +//! *model-visible* content a pending gate renders from and carries only a +//! `SafeSummary`; a `ReplayPayload` is **host-private** and carries the raw tool +//! input. It must never be model-visible. Moving it host-side also retires a +//! real exposure — raw tool input no longer round-trips through the loop's +//! serialized checkpoint. +//! +//! This lives in `ironclaw_capabilities` (not `ironclaw_run_state`) because the +//! `ironclaw_run_state` charter forbids persisting raw replay input in run-state +//! records (`CLAUDE.md` line 7), and the `ironclaw_turns` charter forbids +//! persisting raw tool input in turn state or events — whereas +//! `ironclaw_capabilities` owns the caller-facing invoke/resume/spawn workflow +//! this payload exists to serve, and has no such prohibition. The record embeds +//! the resume-payload field types owned by `ironclaw_turns` +//! ([`CapabilityInputRef`], [`AuthResumeApprovalIdentity`]) rather than +//! re-typing them, per `type-placement.md`. +//! +//! The durable store mirrors `ironclaw_run_state`'s `FilesystemGateRecordStore`: +//! a [`ScopedFilesystem`] over any [`RootFilesystem`], the shared lock-free +//! [`cas_update`] lane (fail-closed on non-CAS backends), a `RecordKind` tag so +//! byte-only backends are rejected, and a private [`StoredReplayPayload`] wrapper +//! carrying the scope for a `same_scope_owner` defense-in-depth check. + +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_filesystem::{ + CasApply, CasUpdateError, ContentType, Entry, FilesystemError, RecordKind, RootFilesystem, + ScopedFilesystem, cas_update, +}; +use ironclaw_host_api::{ + CorrelationId, HostApiError, InvocationId, ResourceEstimate, ResourceScope, ScopedPath, +}; +use ironclaw_turns::run_profile::{AuthResumeApprovalIdentity, CapabilityInputRef}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Host-private replay payload for a gate/auth resume, keyed by [`InvocationId`]. +/// +/// Reuses the exact field types carried by +/// [`CapabilityApprovalResume`](ironclaw_turns::run_profile::CapabilityApprovalResume) +/// / [`CapabilityAuthResume`](ironclaw_turns::run_profile::CapabilityAuthResume) +/// so a later resume-read slice reconstitutes them without any lossy re-typing. +/// +/// **Never model-visible.** Unlike a +/// [`GateRecord`](ironclaw_host_api::GateRecord) this deliberately carries no +/// `SafeSummary` — it holds the raw tool `input` and `estimate` and exists only +/// for host-side re-dispatch. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReplayPayload { + /// Raw runtime input captured when the gate was produced. + pub input: serde_json::Value, + /// Resource estimate captured alongside the input. + pub estimate: ResourceEstimate, + /// Present when the invocation previously passed a one-shot approval gate; + /// carries the prior approval identity so auth-resume can claim the matching + /// fingerprinted lease without a second human approval. + pub prior_approval: Option, + /// Loop-run-scoped input ref the gate was raised against. + pub input_ref: CapabilityInputRef, + /// Correlation id restored onto the invocation context on resume. + pub correlation_id: CorrelationId, +} + +/// Replay-payload persistence errors. +#[derive(Debug, Error)] +pub enum ReplayPayloadStoreError { + /// Write-once violation: a payload already exists for this invocation. + #[error("replay payload for invocation {invocation_id} already exists")] + ReplayPayloadAlreadyExists { invocation_id: InvocationId }, + #[error("invalid storage path: {0}")] + InvalidPath(String), + #[error("filesystem error: {0}")] + Filesystem(String), + #[error("serialization error: {0}")] + Serialization(String), + #[error("deserialization error: {0}")] + Deserialization(String), + #[error("replay payload backend error: {0}")] + Backend(String), +} + +impl From for ReplayPayloadStoreError { + fn from(error: FilesystemError) -> Self { + Self::Filesystem(error.to_string()) + } +} + +/// Durable store for the host-private [`ReplayPayload`] a gate/auth resume +/// reconstitutes from (arch-simplification §5.3). +/// +/// This is a dependency-inversion port (`type-placement.md` §"Traits" reason 2 / +/// 4): defined in this kernel crate, implemented by +/// [`FilesystemReplayPayloadStore`] and wired at composition — the same single- +/// production-impl shape as `ironclaw_run_state`'s `GateRecordStore` it mirrors. +/// +/// Resource-owner scoped; wrong-scope lookups look unknown (`Ok(None)`). It +/// intentionally exposes no removal method: the replay payload is consumed once +/// on resume, and — like the sibling `GateRecordStore` — there is no scope-safe +/// soft-delete to mirror. Hard deletion of a retained record needs an explicit +/// product/retention contract (`database.md` "Data safety"); a later retention +/// slice can add it. +#[async_trait] +pub trait ReplayPayloadStore: Send + Sync { + /// Persists the replay payload for `invocation_id` in the exact + /// resource-owner scope. + /// + /// Write-once: an `invocation_id` that already has a payload is a + /// [`ReplayPayloadStoreError::ReplayPayloadAlreadyExists`]. `InvocationId`s + /// are freshly minted per invocation, so a collision is a caller-invariant + /// violation, not an update path. + async fn save( + &self, + scope: ResourceScope, + invocation_id: InvocationId, + payload: ReplayPayload, + ) -> Result<(), ReplayPayloadStoreError>; + + /// Loads the replay payload for `invocation_id`; a wrong-scope lookup must + /// look unknown (`Ok(None)`), never leak another owner's payload. + async fn load( + &self, + scope: &ResourceScope, + invocation_id: InvocationId, + ) -> Result, ReplayPayloadStoreError>; +} + +/// `RecordKind` tag written on every replay-payload entry so byte-only backends +/// (e.g. `DiskFilesystem`) are rejected with `Unsupported{WriteFile}` on first +/// put, which `cas_update` maps to `CasUnsupported` (fail-closed). +const REPLAY_PAYLOAD_RECORD_KIND: &str = "replay_payload_record"; + +/// Durable wrapper carrying the resource-owner scope alongside the +/// [`ReplayPayload`]. `ReplayPayload` has no scope field; persisting the scope +/// beside it lets [`FilesystemReplayPayloadStore::load`] apply the same +/// `same_scope_owner` defense-in-depth check the sibling gate-record store does, +/// so a wrong-scope read looks unknown. The scope is storage metadata only — +/// `load` returns the bare [`ReplayPayload`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct StoredReplayPayload { + scope: ResourceScope, + payload: ReplayPayload, +} + +/// Filesystem-backed replay-payload store under the `/replay-payloads` mount +/// alias. +/// +/// Mirrors `ironclaw_run_state`'s `FilesystemGateRecordStore`: construct with a +/// [`ScopedFilesystem`] over any [`RootFilesystem`]. The [`ScopedFilesystem`] +/// resolves the `/replay-payloads` alias to a tenant/user-scoped +/// [`VirtualPath`](ironclaw_host_api::VirtualPath) per its +/// [`MountView`](ironclaw_host_api::MountView) and enforces per-op ACL before +/// any backend dispatch — so tenant isolation is structural. Within-tenant axes +/// (agent/project/mission/thread) remain in the alias-relative path because they +/// are not covered by the per-tenant `MountAlias`. +pub struct FilesystemReplayPayloadStore +where + F: RootFilesystem, +{ + filesystem: Arc>, +} + +impl FilesystemReplayPayloadStore +where + F: RootFilesystem, +{ + pub fn new(filesystem: Arc>) -> Self { + Self { filesystem } + } + + fn record_entry(record: &StoredReplayPayload) -> Result { + let body = serialize_pretty(record)?; + let kind = RecordKind::new(REPLAY_PAYLOAD_RECORD_KIND) + .map_err(|e| ReplayPayloadStoreError::Backend(e.to_string()))?; + let mut entry = Entry::bytes(body).with_content_type(ContentType::json()); + entry.kind = Some(kind); + Ok(entry) + } +} + +#[async_trait] +impl ReplayPayloadStore for FilesystemReplayPayloadStore +where + F: RootFilesystem, +{ + async fn save( + &self, + scope: ResourceScope, + invocation_id: InvocationId, + payload: ReplayPayload, + ) -> Result<(), ReplayPayloadStoreError> { + let path = replay_payload_path(&scope, invocation_id)?; + let stored = StoredReplayPayload { + scope: scope.clone(), + payload, + }; + cas_update( + self.filesystem.as_ref(), + &scope, + &path, + |bytes: &[u8]| deserialize::(bytes), + |r: &StoredReplayPayload| Self::record_entry(r), + |current: Option| { + let fresh = stored.clone(); + // Write-once: reject a duplicate invocation rather than clobbering + // the host-private payload a later resume turn still needs. + let outcome = if current.is_some() { + Err(ReplayPayloadStoreError::ReplayPayloadAlreadyExists { invocation_id }) + } else { + Ok(CasApply::new(fresh, ())) + }; + async move { outcome } + }, + ) + .await + .map_err(map_cas_error) + } + + async fn load( + &self, + scope: &ResourceScope, + invocation_id: InvocationId, + ) -> Result, ReplayPayloadStoreError> { + let path = replay_payload_path(scope, invocation_id)?; + let Some(versioned) = self.filesystem.get(scope, &path).await? else { + return Ok(None); + }; + let stored = deserialize::(&versioned.entry.body)?; + // Defense-in-depth against a shared-path read; wrong scope looks unknown. + if same_scope_owner(&stored.scope, scope) { + Ok(Some(stored.payload)) + } else { + Ok(None) + } + } +} + +// Path layout under the `/replay-payloads` mount alias: +// +// /replay-payloads[/agents/][/projects/][/missions/][/threads/]/.json +// +// Tenant + user identity moves into the caller's `MountView` per the per-tenant +// `MountAlias` rewriting, so neither prefix is encoded in the path itself. +// Within-tenant sub-scope axes (agent/project/mission/thread) stay in the +// alias-relative path because they are within-tenant scoping not covered by the +// per-tenant `MountAlias`. Mirrors `ironclaw_run_state`'s `/gate-records` layout. + +const REPLAY_PAYLOADS_PREFIX: &str = "/replay-payloads"; + +fn replay_payload_path( + scope: &ResourceScope, + invocation_id: InvocationId, +) -> Result { + scoped_path(&format!( + "{}/{invocation_id}.json", + scope_owner_alias_string(REPLAY_PAYLOADS_PREFIX, scope) + )) +} + +/// Build the alias-relative owner prefix for a scope under the given mount +/// alias. Tenant and user are intentionally absent — they live in the +/// `MountView` the caller supplied. Sub-scope axes (agent/project/mission/ +/// thread) stay in the path so within-tenant cross-scope isolation still works +/// for stores sharing one alias target. Mirrors the sibling helper in +/// `ironclaw_run_state`. +fn scope_owner_alias_string(prefix: &'static str, scope: &ResourceScope) -> String { + let mut base = String::from(prefix); + if let Some(agent_id) = &scope.agent_id { + base.push_str("/agents/"); + base.push_str(agent_id.as_str()); + } + if let Some(project_id) = &scope.project_id { + base.push_str("/projects/"); + base.push_str(project_id.as_str()); + } + if let Some(mission_id) = &scope.mission_id { + base.push_str("/missions/"); + base.push_str(mission_id.as_str()); + } + if let Some(thread_id) = &scope.thread_id { + base.push_str("/threads/"); + base.push_str(thread_id.as_str()); + } + base +} + +fn scoped_path(raw: &str) -> Result { + ScopedPath::new(raw).map_err(invalid_path) +} + +fn invalid_path(error: HostApiError) -> ReplayPayloadStoreError { + ReplayPayloadStoreError::InvalidPath(error.to_string()) +} + +fn same_scope_owner(left: &ResourceScope, right: &ResourceScope) -> bool { + left.tenant_id == right.tenant_id + && left.user_id == right.user_id + && left.agent_id == right.agent_id + && left.project_id == right.project_id + && left.mission_id == right.mission_id + && left.thread_id == right.thread_id +} + +fn serialize_pretty(value: &T) -> Result, ReplayPayloadStoreError> +where + T: Serialize, +{ + serde_json::to_vec_pretty(value) + .map_err(|error| ReplayPayloadStoreError::Serialization(error.to_string())) +} + +fn deserialize(bytes: &[u8]) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_slice(bytes) + .map_err(|error| ReplayPayloadStoreError::Deserialization(error.to_string())) +} + +/// Map the shared CAS helper's [`CasUpdateError`] into a +/// [`ReplayPayloadStoreError`], preserving the caller's own error and failing +/// closed on a backend that cannot honor versioned CAS (mirrors +/// `ironclaw_run_state`'s `map_cas_error`). +fn map_cas_error(error: CasUpdateError) -> ReplayPayloadStoreError { + match error { + CasUpdateError::Apply(inner) => inner, + CasUpdateError::Timeout | CasUpdateError::RetriesExhausted => { + ReplayPayloadStoreError::Backend("filesystem CAS retries exhausted".to_string()) + } + CasUpdateError::CasUnsupported => ReplayPayloadStoreError::Backend( + "backend does not support versioned compare-and-swap".to_string(), + ), + CasUpdateError::Backend(fs_err) => ReplayPayloadStoreError::Filesystem(fs_err.to_string()), + } +} diff --git a/crates/ironclaw_capabilities/tests/replay_payload_store_contract.rs b/crates/ironclaw_capabilities/tests/replay_payload_store_contract.rs new file mode 100644 index 00000000000..873fbe22fba --- /dev/null +++ b/crates/ironclaw_capabilities/tests/replay_payload_store_contract.rs @@ -0,0 +1,229 @@ +//! Contract tests for [`ReplayPayloadStore`] / [`FilesystemReplayPayloadStore`]. +//! +//! The replay payload is the **host-private** raw capability input + estimate a +//! gate/auth resume replays through (arch-simplification §5.3). It is the exact +//! opposite of a `GateRecord`: it must NEVER be model-visible — it carries the +//! raw tool `input` JSON and `ResourceEstimate` that today ride in-band through +//! the untrusted loop checkpoint. Moving it host-side (keyed by `InvocationId`) +//! retires that exposure, so these tests pin the seam a later resume-read slice +//! depends on: an all-fields round-trip (proving raw `input`/`estimate` survive), +//! the auth-without-prior-approval shape, a missing key reads as `None`, a +//! duplicate key is rejected write-once, and a payload saved under one scope is +//! not loadable under another (the cross-tenant + within-tenant regression +//! `database.md` / `safety-and-sandbox.md` require). +//! +//! Mirrors `ironclaw_run_state`'s `gate_record_store_contract.rs`. + +use std::sync::Arc; + +use ironclaw_capabilities::{ + FilesystemReplayPayloadStore, ReplayPayload, ReplayPayloadStore, ReplayPayloadStoreError, +}; +use ironclaw_filesystem::{InMemoryBackend, RootFilesystem, ScopedFilesystem}; +use ironclaw_host_api::{ + ApprovalRequestId, CorrelationId, InvocationId, MountAlias, MountGrant, MountPermissions, + MountView, ProjectId, ResourceEstimate, ResourceScope, TenantId, UserId, VirtualPath, +}; +use ironclaw_turns::run_profile::{AuthResumeApprovalIdentity, CapabilityInputRef}; + +#[tokio::test] +async fn replay_payload_round_trips_all_fields() { + let store = in_mem_replay_payload_store(); + let scope = sample_scope("tenant1", "user1"); + let invocation_id = InvocationId::new(); + // A payload that populated every field: raw input JSON, a non-trivial + // estimate, a prior-approval identity, an input ref, and a correlation id. + let payload = payload_with_prior_approval(); + + store + .save(scope.clone(), invocation_id, payload.clone()) + .await + .unwrap(); + + let loaded = store.load(&scope, invocation_id).await.unwrap(); + assert_eq!( + loaded, + Some(payload), + "save then load must reconstruct every field, including the raw input and estimate" + ); +} + +#[tokio::test] +async fn replay_payload_without_prior_approval_round_trips() { + // The auth-resume-without-approval shape: `prior_approval` is `None`. This is + // a distinct scenario from the all-fields case (the auth gate that never + // passed an approval gate), so it gets its own assertion rather than being + // folded into the round-trip above. + let store = in_mem_replay_payload_store(); + let scope = sample_scope("tenant1", "user1"); + let invocation_id = InvocationId::new(); + let payload = payload_without_prior_approval(); + + store + .save(scope.clone(), invocation_id, payload.clone()) + .await + .unwrap(); + + match store.load(&scope, invocation_id).await.unwrap() { + Some(loaded) => { + assert_eq!(loaded.prior_approval, None); + assert_eq!(loaded, payload); + } + None => panic!("expected the saved payload to load"), + } +} + +#[tokio::test] +async fn replay_payload_load_of_missing_returns_none() { + let store = in_mem_replay_payload_store(); + let scope = sample_scope("tenant1", "user1"); + + let loaded = store.load(&scope, InvocationId::new()).await.unwrap(); + + assert_eq!(loaded, None); +} + +#[tokio::test] +async fn replay_payload_save_is_write_once() { + let store = in_mem_replay_payload_store(); + let scope = sample_scope("tenant1", "user1"); + let invocation_id = InvocationId::new(); + let first = payload_with_prior_approval(); + store + .save(scope.clone(), invocation_id, first.clone()) + .await + .unwrap(); + + let err = store + .save( + scope.clone(), + invocation_id, + payload_without_prior_approval(), + ) + .await + .unwrap_err(); + + assert!(matches!( + err, + ReplayPayloadStoreError::ReplayPayloadAlreadyExists { invocation_id: id } if id == invocation_id + )); + // The original write-once payload is intact — the rejected save did not clobber it. + assert_eq!( + store.load(&scope, invocation_id).await.unwrap(), + Some(first) + ); +} + +#[tokio::test] +async fn replay_payload_load_is_scoped_to_tenant_and_user() { + let store = in_mem_replay_payload_store(); + let invocation_id = InvocationId::new(); + let tenant_a = sample_scope("tenant1", "user1"); + let tenant_b = sample_scope("tenant2", "user1"); + let payload = payload_with_prior_approval(); + + store + .save(tenant_a.clone(), invocation_id, payload.clone()) + .await + .unwrap(); + + // A payload saved under tenant A must not be loadable under tenant B. + assert_eq!(store.load(&tenant_b, invocation_id).await.unwrap(), None); + // The owner still sees it. + assert_eq!( + store.load(&tenant_a, invocation_id).await.unwrap(), + Some(payload) + ); +} + +#[tokio::test] +async fn replay_payload_load_is_scoped_to_within_tenant_axes() { + // Same tenant/user, different project — path-level within-tenant isolation. + let store = in_mem_replay_payload_store(); + let invocation_id = InvocationId::new(); + let project_a = scope_with_project("tenant1", "user1", "project-a"); + let project_b = scope_with_project("tenant1", "user1", "project-b"); + let payload = payload_with_prior_approval(); + + store + .save(project_a.clone(), invocation_id, payload.clone()) + .await + .unwrap(); + + assert_eq!(store.load(&project_b, invocation_id).await.unwrap(), None); + assert_eq!( + store.load(&project_a, invocation_id).await.unwrap(), + Some(payload) + ); +} + +fn payload_with_prior_approval() -> ReplayPayload { + ReplayPayload { + input: serde_json::json!({ + "path": "/etc/hosts", + "mode": "read", + "nested": { "count": 3, "flag": true }, + }), + estimate: ResourceEstimate::default() + .set_input_tokens(1_200) + .set_wall_clock_ms(750) + .set_output_bytes(4_096), + prior_approval: Some(AuthResumeApprovalIdentity { + approval_request_id: ApprovalRequestId::new(), + correlation_id: CorrelationId::new(), + }), + input_ref: CapabilityInputRef::new("input:round-trip-fixture").unwrap(), + correlation_id: CorrelationId::new(), + } +} + +fn payload_without_prior_approval() -> ReplayPayload { + ReplayPayload { + input: serde_json::json!({ "query": "select 1" }), + estimate: ResourceEstimate::default().set_process_count(1), + prior_approval: None, + input_ref: CapabilityInputRef::new("input:no-approval-fixture").unwrap(), + correlation_id: CorrelationId::new(), + } +} + +/// The production replay-payload store over a fresh in-memory backend. +fn in_mem_replay_payload_store() -> FilesystemReplayPayloadStore { + FilesystemReplayPayloadStore::new(scoped_replay_payload_fs(Arc::new(InMemoryBackend::new()))) +} + +/// Build a [`ScopedFilesystem`] exposing the `/replay-payloads` alias under a +/// single tenant/user subtree of the underlying mount — mirrors the production +/// shape where one `MountView` covers a consumer alias for a given tenant/user. +fn scoped_replay_payload_fs(backend: Arc) -> Arc> +where + F: RootFilesystem, +{ + let mounts = MountView::new(vec![MountGrant::new( + MountAlias::new("/replay-payloads").expect("alias"), + VirtualPath::new("/engine/tenants/test-tenant/users/test-user/replay-payloads") + .expect("target"), + MountPermissions::read_write_list_delete(), + )]) + .expect("mount view"); + Arc::new(ScopedFilesystem::with_fixed_view(backend, mounts)) +} + +fn sample_scope(tenant: &str, user: &str) -> ResourceScope { + ResourceScope { + tenant_id: TenantId::new(tenant).unwrap(), + user_id: UserId::new(user).unwrap(), + agent_id: None, + project_id: Some(ProjectId::new("project1").unwrap()), + mission_id: None, + thread_id: None, + invocation_id: InvocationId::new(), + } +} + +fn scope_with_project(tenant: &str, user: &str, project: &str) -> ResourceScope { + ResourceScope { + project_id: Some(ProjectId::new(project).unwrap()), + ..sample_scope(tenant, user) + } +} diff --git a/crates/ironclaw_host_api/src/gate_record.rs b/crates/ironclaw_host_api/src/gate_record.rs index 23abb78f7bb..18343c29548 100644 --- a/crates/ironclaw_host_api/src/gate_record.rs +++ b/crates/ironclaw_host_api/src/gate_record.rs @@ -84,6 +84,14 @@ pub enum GateRecord { summary: SafeSummary, result: ResultRef, byte_len: u64, + /// The preserved originating loop result ref the staged result was + /// keyed under (§5.3 Stage 1 non-lossy carry): `result` is a freshly + /// minted uuid handle, so without this the child output the loop staged + /// under its own ref would become unreachable from the durable record a + /// later resume turn renders from. `None` on records persisted before + /// this field existed (serde default keeps old rows rehydratable). + #[serde(default, skip_serializing_if = "Option::is_none")] + result_origin: Option, }, /// Awaiting a client-executed external tool the host does not run. ExternalTool { summary: SafeSummary }, @@ -149,6 +157,7 @@ mod tests { summary: summary(), result, byte_len: 2048, + result_origin: Some(crate::LoopRef::new("result:child-1").unwrap()), }, "dependent_run", ), diff --git a/crates/ironclaw_host_api/src/lib.rs b/crates/ironclaw_host_api/src/lib.rs index f8d401df772..9ac5d4e993f 100644 --- a/crates/ironclaw_host_api/src/lib.rs +++ b/crates/ironclaw_host_api/src/lib.rs @@ -53,6 +53,7 @@ pub mod mount; pub mod path; pub mod resolution; pub mod resource; +pub mod result_meta; pub mod runtime; pub mod runtime_policy; pub mod safe_summary; @@ -83,6 +84,7 @@ pub use mount::*; pub use path::*; pub use resolution::*; pub use resource::*; +pub use result_meta::*; pub use runtime::*; pub use runtime_policy::*; pub use safe_summary::*; diff --git a/crates/ironclaw_host_api/src/resolution.rs b/crates/ironclaw_host_api/src/resolution.rs index 7575e7a1bfb..95a9b4b4866 100644 --- a/crates/ironclaw_host_api/src/resolution.rs +++ b/crates/ironclaw_host_api/src/resolution.rs @@ -36,7 +36,83 @@ use serde::{Deserialize, Serialize}; -use crate::{DenyRef, GateRef, ProcessRef, ResultRef, RunId, SafeSummary}; +use crate::{ + DenyRef, FailureKind, GateRef, LoopRef, OutputDigest, ProcessRef, ResultProgress, ResultRef, + ResumeToken, RunId, SafeSummary, TerminateHint, +}; + +/// A pending-gate handle plus the additive context needed to resume and correlate +/// it (§5.3 Stage 1). Three parts, each plain redacted vocabulary: +/// +/// - `gate` — the opaque kernel [`GateRef`] the pending record is keyed by; +/// - `origin` — the preserved *originating* loop gate ref, so loop/evidence state +/// keyed under it stays reachable once the uuid handle is minted; +/// - `resume` — the opaque [`ResumeToken`] the loop echoes back to re-enter +/// `authorize()`. Populated only for approval/auth gates; a resource gate +/// resumes against *then-current* budget (§5.3.3), not a token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GateWaypoint { + /// The opaque kernel handle to the pending gate record. + pub gate: GateRef, + /// The preserved originating loop gate ref, when one was carried. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + /// The opaque gate-resume identity the loop echoes back (approval/auth only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume: Option, +} + +impl GateWaypoint { + /// A bare waypoint carrying only the kernel handle. Use the `with_*` setters + /// to add the preserved origin and/or resume token (default-backed builder). + pub fn new(gate: GateRef) -> Self { + Self { + gate, + origin: None, + resume: None, + } + } + + /// Preserve the originating loop gate ref. + pub fn with_origin(mut self, origin: LoopRef) -> Self { + self.origin = Some(origin); + self + } + + /// Carry the gate-resume token the loop echoes back. + pub fn with_resume(mut self, resume: ResumeToken) -> Self { + self.resume = Some(resume); + self + } +} + +/// A spawned-process handle plus the preserved originating loop process ref +/// (§5.3 Stage 1). Parked-work suspensions resume when the awaited process +/// completes, so — unlike [`GateWaypoint`] — there is no resume token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessWaypoint { + /// The opaque kernel handle to the spawned-process record. + pub process: ProcessRef, + /// The preserved originating loop process ref, when one was carried. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, +} + +impl ProcessWaypoint { + /// A bare waypoint carrying only the kernel handle. + pub fn new(process: ProcessRef) -> Self { + Self { + process, + origin: None, + } + } + + /// Preserve the originating loop process ref. + pub fn with_origin(mut self, origin: LoopRef) -> Self { + self.origin = Some(origin); + self + } +} /// A re-entrant gate: the invocation did not run and is waiting on a decision. /// Resolving the gate re-enters `authorize()` (§5.3.3: a resolved gate reserves @@ -52,22 +128,38 @@ use crate::{DenyRef, GateRef, ProcessRef, ResultRef, RunId, SafeSummary}; #[serde(rename_all = "snake_case")] pub enum Blocked { /// Needs human approval before it may run. - Approval(GateRef), + Approval(GateWaypoint), /// Needs a credential the caller has not supplied (auth gate). The only kind /// `dispatch()` may surface (§5.3.1). - Auth(GateRef), + Auth(GateWaypoint), /// Needs resource budget currently unavailable. - Resource(GateRef), + Resource(GateWaypoint), } impl Blocked { - /// The handle to the pending gate record, regardless of kind. - pub fn gate_ref(&self) -> &GateRef { + /// The gate waypoint (kernel handle + preserved origin + resume token), + /// regardless of kind. + pub fn waypoint(&self) -> &GateWaypoint { match self { - Blocked::Approval(g) | Blocked::Auth(g) | Blocked::Resource(g) => g, + Blocked::Approval(w) | Blocked::Auth(w) | Blocked::Resource(w) => w, } } + /// The handle to the pending gate record, regardless of kind. + pub fn gate_ref(&self) -> &GateRef { + &self.waypoint().gate + } + + /// The preserved originating loop gate ref, when one was carried. + pub fn origin(&self) -> Option<&LoopRef> { + self.waypoint().origin.as_ref() + } + + /// The gate-resume token the loop echoes back (approval/auth only). + pub fn resume_token(&self) -> Option<&ResumeToken> { + self.waypoint().resume.as_ref() + } + /// Stable discriminant (matches the serde tag) for logs/routing. pub fn kind(&self) -> &'static str { match self { @@ -98,12 +190,12 @@ impl Blocked { #[serde(rename_all = "snake_case")] pub enum Suspension { /// A spawned OS process the turn now waits on. - Process(ProcessRef), + Process(ProcessWaypoint), /// A dependent child run this invocation awaits. - DependentRun(GateRef), + DependentRun(GateWaypoint), /// A client-supplied tool the host does not execute; control returns to the /// API client until it submits the output. - ExternalTool(GateRef), + ExternalTool(GateWaypoint), } impl Suspension { @@ -121,7 +213,7 @@ impl Suspension { /// record instead — see [`Suspension::process_ref`]. pub fn gate_ref(&self) -> Option<&GateRef> { match self { - Suspension::DependentRun(gate) | Suspension::ExternalTool(gate) => Some(gate), + Suspension::DependentRun(w) | Suspension::ExternalTool(w) => Some(&w.gate), Suspension::Process(_) => None, } } @@ -129,10 +221,18 @@ impl Suspension { /// The process record this suspension tracks, when it is process-shaped. pub fn process_ref(&self) -> Option<&ProcessRef> { match self { - Suspension::Process(process) => Some(process), + Suspension::Process(w) => Some(&w.process), Suspension::DependentRun(_) | Suspension::ExternalTool(_) => None, } } + + /// The preserved originating loop ref (gate or process), regardless of kind. + pub fn origin(&self) -> Option<&LoopRef> { + match self { + Suspension::Process(w) => w.origin.as_ref(), + Suspension::DependentRun(w) | Suspension::ExternalTool(w) => w.origin.as_ref(), + } + } } /// The typed verdict of a dispatched capability — success or a recoverable @@ -149,14 +249,23 @@ impl Suspension { /// `SpawnedChildRun.child_run_id`) so the invariant "a child ref exists exactly /// when a child was spawned" is unrepresentable to violate — no optional field /// to validate at construction or deserialization. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// +/// `RecoverableFailure` carries the recovery classification ([`FailureKind`], was +/// `CapabilityFailure::error_kind`) *on the variant* for the same reason: the +/// class that drives retry-vs-terminal exists exactly when the verdict is a +/// recoverable failure. `FailureKind` is a bounded taxonomy, never the raw backend +/// cause — that stays host-side. +/// +/// Not `Copy` (unlike the earlier slice): `FailureKind::Unknown` owns a `String`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolVerdict { /// The capability ran and succeeded. Success, /// The capability ran and failed in a model-visible, correctable way — NOT a /// `HostFailure` (that is infrastructure, §1.2). The model may retry or adapt. - RecoverableFailure, + /// Carries the [`FailureKind`] recovery classification. + RecoverableFailure { error_kind: FailureKind }, /// The capability spawned a child run; non-suspending (§5.3 table). Carries /// the child's [`RunId`] — a correlation ref, safe on the sanitized boundary. ChildSpawned { child_run: RunId }, @@ -168,6 +277,15 @@ impl ToolVerdict { matches!(self, ToolVerdict::Success) } + /// The recovery classification, present exactly on + /// [`ToolVerdict::RecoverableFailure`]. + pub fn error_kind(&self) -> Option<&FailureKind> { + match self { + ToolVerdict::RecoverableFailure { error_kind } => Some(error_kind), + _ => None, + } + } + /// The spawned child run, present exactly on [`ToolVerdict::ChildSpawned`]. pub fn child_run(&self) -> Option { match self { @@ -180,7 +298,7 @@ impl ToolVerdict { pub fn kind(&self) -> &'static str { match self { ToolVerdict::Success => "success", - ToolVerdict::RecoverableFailure => "recoverable_failure", + ToolVerdict::RecoverableFailure { .. } => "recoverable_failure", ToolVerdict::ChildSpawned { .. } => "child_spawned", } } @@ -201,6 +319,17 @@ pub struct OutcomeRefs { /// when no preview is staged. #[serde(default, skip_serializing_if = "Option::is_none")] pub preview: Option, + /// The preserved originating loop result ref, so output the loop staged under + /// its own ref stays reachable once `result` (a uuid handle) is minted. `None` + /// when the outcome had no originating loop result ref (e.g. a recoverable + /// failure stages nothing). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + /// Stable digest over the normalized output content (was + /// `CapabilityResultMessage::output_digest`) — a fixed-width hash, never the + /// content. `None` for synthetic results that stage no real output. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_digest: Option, } /// A dispatched capability's result — tool success OR recoverable failure (§3). @@ -215,6 +344,25 @@ pub struct Outcome { pub refs: OutcomeRefs, pub verdict: ToolVerdict, pub summary: SafeSummary, + /// Loop-derived signal describing whether this result advanced the loop's + /// evidence/state (was `CapabilityResultMessage::progress`). Defaults to + /// [`ResultProgress::Unknown`] for outcomes that carry no progress signal + /// (a recoverable failure, a spawned child). + #[serde(default, skip_serializing_if = "is_default_progress")] + pub progress: ResultProgress, + /// Host hint that the loop should end naturally after the current batch (was + /// `CapabilityResultMessage::terminate_hint`). Defaults to + /// [`TerminateHint::Continue`]. + #[serde(default, skip_serializing_if = "is_default_terminate_hint")] + pub terminate_hint: TerminateHint, +} + +fn is_default_progress(progress: &ResultProgress) -> bool { + *progress == ResultProgress::default() +} + +fn is_default_terminate_hint(hint: &TerminateHint) -> bool { + *hint == TerminateHint::default() } /// The composed answer of one capability invocation — the single value @@ -279,24 +427,36 @@ mod tests { GateRef::parse(GATE_UUID).unwrap() } + fn gate_wp() -> GateWaypoint { + GateWaypoint::new(gate()) + } + fn proc_ref() -> ProcessRef { ProcessRef::parse(PROC_UUID).unwrap() } + fn proc_wp() -> ProcessWaypoint { + ProcessWaypoint::new(proc_ref()) + } + #[test] fn blocked_serde_is_snake_case_tagged_and_roundtrips() { - let blocked = Blocked::Approval(gate()); + let blocked = Blocked::Approval(gate_wp()); let json = serde_json::to_value(&blocked).unwrap(); - assert_eq!(json, serde_json::json!({ "approval": GATE_UUID })); + // A bare waypoint (no origin/resume) serializes as just the gate handle. + assert_eq!( + json, + serde_json::json!({ "approval": { "gate": GATE_UUID } }) + ); assert_eq!(serde_json::from_value::(json).unwrap(), blocked); } #[test] fn blocked_kind_matches_serde_tag_and_gate_ref_is_reachable() { for (blocked, tag) in [ - (Blocked::Approval(gate()), "approval"), - (Blocked::Auth(gate()), "auth"), - (Blocked::Resource(gate()), "resource"), + (Blocked::Approval(gate_wp()), "approval"), + (Blocked::Auth(gate_wp()), "auth"), + (Blocked::Resource(gate_wp()), "resource"), ] { let wire = serde_json::to_value(&blocked).unwrap(); let tag_on_wire = wire.as_object().unwrap().keys().next().unwrap().clone(); @@ -306,26 +466,46 @@ mod tests { } } + #[test] + fn blocked_waypoint_carries_preserved_origin_and_resume_token() { + let waypoint = GateWaypoint::new(gate()) + .with_origin(LoopRef::new("gate:approval-1").unwrap()) + .with_resume(ResumeToken::new("resume-1").unwrap()); + let blocked = Blocked::Approval(waypoint); + assert_eq!( + blocked.origin().map(LoopRef::as_str), + Some("gate:approval-1") + ); + assert_eq!( + blocked.resume_token().map(ResumeToken::as_str), + Some("resume-1") + ); + // The preserved fields survive a wire round-trip. + let back: Blocked = + serde_json::from_value(serde_json::to_value(&blocked).unwrap()).unwrap(); + assert_eq!(back, blocked); + } + #[test] fn only_auth_may_be_surfaced_at_dispatch_time() { // §5.3.1: dispatch() may raise only Blocked::Auth. This pins the contract // the conformance test (§11.7) will enforce against lanes. - assert!(Blocked::Auth(gate()).is_dispatch_time_permitted()); - assert!(!Blocked::Approval(gate()).is_dispatch_time_permitted()); - assert!(!Blocked::Resource(gate()).is_dispatch_time_permitted()); + assert!(Blocked::Auth(gate_wp()).is_dispatch_time_permitted()); + assert!(!Blocked::Approval(gate_wp()).is_dispatch_time_permitted()); + assert!(!Blocked::Resource(gate_wp()).is_dispatch_time_permitted()); } #[test] fn suspension_serde_tags_and_kinds_agree() { - let process = Suspension::Process(proc_ref()); + let process = Suspension::Process(proc_wp()); assert_eq!( serde_json::to_value(&process).unwrap(), - serde_json::json!({ "process": PROC_UUID }) + serde_json::json!({ "process": { "process": PROC_UUID } }) ); for (suspension, tag) in [ - (Suspension::Process(proc_ref()), "process"), - (Suspension::DependentRun(gate()), "dependent_run"), - (Suspension::ExternalTool(gate()), "external_tool"), + (Suspension::Process(proc_wp()), "process"), + (Suspension::DependentRun(gate_wp()), "dependent_run"), + (Suspension::ExternalTool(gate_wp()), "external_tool"), ] { let wire = serde_json::to_value(&suspension).unwrap(); let tag_on_wire = wire.as_object().unwrap().keys().next().unwrap().clone(); @@ -343,19 +523,43 @@ mod tests { #[test] fn tool_verdict_serde_tags_and_kinds_agree() { - for (verdict, tag) in [ - (ToolVerdict::Success, "success"), - (ToolVerdict::RecoverableFailure, "recoverable_failure"), - ] { - assert_eq!( - serde_json::to_value(verdict).unwrap(), - serde_json::Value::String(tag.to_string()) - ); - assert_eq!(verdict.kind(), tag); - } + // Success is a unit variant; the other two are struct variants (each + // carries its invariant field on the variant). + assert_eq!( + serde_json::to_value(ToolVerdict::Success).unwrap(), + serde_json::Value::String("success".to_string()) + ); + assert_eq!(ToolVerdict::Success.kind(), "success"); + let failure = ToolVerdict::RecoverableFailure { + error_kind: FailureKind::InvalidInput, + }; + assert_eq!(failure.kind(), "recoverable_failure"); + assert_eq!( + serde_json::to_value(&failure).unwrap(), + serde_json::json!({ "recoverable_failure": { "error_kind": "invalid_input" } }) + ); assert!(ToolVerdict::Success.is_success()); - assert!(!ToolVerdict::RecoverableFailure.is_success()); + assert!(!failure.is_success()); assert_eq!(ToolVerdict::Success.child_run(), None); + assert_eq!(ToolVerdict::Success.error_kind(), None); + assert_eq!(failure.error_kind(), Some(&FailureKind::InvalidInput)); + } + + #[test] + fn recoverable_failure_carries_its_error_kind_across_the_wire() { + // The recovery classification (retry-vs-terminal) survives round-trip — + // the field the old mapping dropped as "G1". + for kind in [ + FailureKind::Network, + FailureKind::unknown("quota_exceeded").unwrap(), + ] { + let verdict = ToolVerdict::RecoverableFailure { + error_kind: kind.clone(), + }; + let back: ToolVerdict = + serde_json::from_value(serde_json::to_value(&verdict).unwrap()).unwrap(); + assert_eq!(back.error_kind(), Some(&kind)); + } } #[test] @@ -367,7 +571,7 @@ mod tests { // Struct variant: externally tagged with the snake_case tag; the child // ref exists exactly when a child was spawned — there is no optional // field whose consistency needs validating. - let wire = serde_json::to_value(verdict).unwrap(); + let wire = serde_json::to_value(&verdict).unwrap(); assert_eq!( wire, serde_json::json!({ @@ -391,9 +595,15 @@ mod tests { result: ResultRef::parse("018f6a00-0000-7000-8000-000000000001").unwrap(), byte_len: 4096, preview: None, + origin: None, + output_digest: None, + }, + verdict: ToolVerdict::RecoverableFailure { + error_kind: FailureKind::InvalidInput, }, - verdict: ToolVerdict::RecoverableFailure, summary: SafeSummary::new("tool input rejected").unwrap(), + progress: ResultProgress::default(), + terminate_hint: TerminateHint::default(), }; let json = serde_json::to_value(&outcome).unwrap(); let back: Outcome = serde_json::from_value(json).unwrap(); @@ -403,6 +613,37 @@ mod tests { assert_eq!(back.refs.byte_len, 4096); } + #[test] + fn outcome_carries_progress_terminate_hint_and_digest_when_populated() { + // The G4 completion signals survive a wire round-trip. + let outcome = Outcome { + refs: OutcomeRefs { + result: ResultRef::parse("018f6a00-0000-7000-8000-000000000001").unwrap(), + byte_len: 10, + preview: None, + origin: Some(LoopRef::new("result:child-1").unwrap()), + output_digest: Some(OutputDigest::new(0xABCD)), + }, + verdict: ToolVerdict::Success, + summary: SafeSummary::new("read 3 files").unwrap(), + progress: ResultProgress::MadeProgress, + terminate_hint: TerminateHint::TerminateAfterBatch, + }; + let back: Outcome = + serde_json::from_value(serde_json::to_value(&outcome).unwrap()).unwrap(); + assert_eq!(back, outcome); + assert_eq!(back.progress, ResultProgress::MadeProgress); + assert!(back.terminate_hint.should_terminate()); + assert_eq!( + back.refs.output_digest.map(OutputDigest::value), + Some(0xABCD) + ); + assert_eq!( + back.refs.origin.as_ref().map(LoopRef::as_str), + Some("result:child-1") + ); + } + #[test] fn outcome_refs_roundtrip_with_optional_preview() { let result = ResultRef::parse("018f6a00-0000-7000-8000-000000000001").unwrap(); @@ -411,6 +652,8 @@ mod tests { result, byte_len: 128, preview: Some(SafeSummary::new("staged 3 rows").unwrap()), + origin: None, + output_digest: None, }; let back: OutcomeRefs = serde_json::from_value(serde_json::to_value(&full).unwrap()).unwrap(); @@ -426,6 +669,8 @@ mod tests { result, byte_len: 128, preview: None, + origin: None, + output_digest: None, }; let wire = serde_json::to_value(&bare).unwrap(); assert_eq!( @@ -434,7 +679,7 @@ mod tests { "result": "018f6a00-0000-7000-8000-000000000001", "byte_len": 128 }), - "a None preview must not appear on the wire" + "None additive fields must not appear on the wire" ); let back: OutcomeRefs = serde_json::from_value(wire).unwrap(); assert_eq!(back, bare); @@ -463,15 +708,25 @@ mod tests { } } + fn recoverable_failure() -> ToolVerdict { + ToolVerdict::RecoverableFailure { + error_kind: FailureKind::InvalidInput, + } + } + fn outcome(verdict: ToolVerdict) -> Outcome { Outcome { refs: OutcomeRefs { result: ResultRef::parse("018f6a00-0000-7000-8000-000000000001").unwrap(), byte_len: 1, preview: None, + origin: None, + output_digest: None, }, verdict, summary: SafeSummary::new("ok").unwrap(), + progress: ResultProgress::default(), + terminate_hint: TerminateHint::default(), } } @@ -491,7 +746,7 @@ mod tests { ), ( "Failed", - Resolution::Done(outcome(ToolVerdict::RecoverableFailure)), + Resolution::Done(outcome(recoverable_failure())), false, ), ( @@ -501,22 +756,22 @@ mod tests { ), ( "ApprovalRequired", - Resolution::Blocked(Blocked::Approval(gate())), + Resolution::Blocked(Blocked::Approval(GateWaypoint::new(gate()))), false, ), ( "AuthRequired", - Resolution::Blocked(Blocked::Auth(gate())), + Resolution::Blocked(Blocked::Auth(GateWaypoint::new(gate()))), false, ), ( "ResourceBlocked", - Resolution::Blocked(Blocked::Resource(gate())), + Resolution::Blocked(Blocked::Resource(GateWaypoint::new(gate()))), false, ), ( "SpawnedProcess", - Resolution::Suspended(Suspension::Process(proc())), + Resolution::Suspended(Suspension::Process(ProcessWaypoint::new(proc()))), true, ), // Non-suspending — the one that has bitten before (#6137, §5.3 table). @@ -527,12 +782,12 @@ mod tests { ), ( "AwaitDependentRun", - Resolution::Suspended(Suspension::DependentRun(gate())), + Resolution::Suspended(Suspension::DependentRun(GateWaypoint::new(gate()))), true, ), ( "ExternalToolPending", - Resolution::Suspended(Suspension::ExternalTool(gate())), + Resolution::Suspended(Suspension::ExternalTool(GateWaypoint::new(gate()))), true, ), ]; @@ -553,12 +808,12 @@ mod tests { #[test] fn resolution_channel_predicates() { assert!( - Resolution::Suspended(Suspension::Process( + Resolution::Suspended(Suspension::Process(ProcessWaypoint::new( ProcessRef::parse("0f0e0d0c-0b0a-4908-8706-050403020100").unwrap() - )) + ))) .is_suspension() ); - assert!(Resolution::Blocked(Blocked::Approval(gate())).is_reentrant_gate()); + assert!(Resolution::Blocked(Blocked::Approval(gate_wp())).is_reentrant_gate()); // Denied is terminal — not a re-entrant gate, not a suspension. let denied = Resolution::Denied(DenyRef::parse("018f6a00-0000-7000-8000-000000000002").unwrap()); @@ -595,15 +850,15 @@ mod tests { serde_json::json!({ "denied": "018f6a00-0000-7000-8000-000000000002" }), ), ( - Resolution::Blocked(Blocked::Approval(gate)), + Resolution::Blocked(Blocked::Approval(GateWaypoint::new(gate))), serde_json::json!({ - "blocked": { "approval": "01890a5d-ac96-774b-bcce-b302099a8057" } + "blocked": { "approval": { "gate": "01890a5d-ac96-774b-bcce-b302099a8057" } } }), ), ( - Resolution::Suspended(Suspension::Process(proc)), + Resolution::Suspended(Suspension::Process(ProcessWaypoint::new(proc))), serde_json::json!({ - "suspended": { "process": "0f0e0d0c-0b0a-4908-8706-050403020100" } + "suspended": { "process": { "process": "0f0e0d0c-0b0a-4908-8706-050403020100" } } }), ), ]; diff --git a/crates/ironclaw_host_api/src/result_meta.rs b/crates/ironclaw_host_api/src/result_meta.rs new file mode 100644 index 00000000000..5257eb46c76 --- /dev/null +++ b/crates/ironclaw_host_api/src/result_meta.rs @@ -0,0 +1,572 @@ +//! Slice-C kernel vocabulary — loop-derived result metadata, gate resume +//! identity, and preserved originating loop refs (arch-simplification §3/§5.3 +//! **Stage 1**). +//! +//! These types make [`Resolution`](crate::Resolution) a **non-lossy** carrier for +//! every `CapabilityOutcome` case, so a later stage can delete that overloaded +//! enum (§5.3). Today the `CapabilityOutcome` → `Resolution` mapping drops five +//! classes of field for want of a host_api home (the old "G1/G4 dropped" +//! comments). This module gives each a home: +//! +//! - [`FailureKind`] — the recovery classification on a recoverable failure +//! (was `CapabilityFailure::error_kind`); it drives retry-vs-terminal. +//! - [`ResultProgress`] / [`TerminateHint`] / [`OutputDigest`] — the loop-derived +//! completion signals (was `CapabilityResultMessage::{progress, terminate_hint, +//! output_digest}`, the "G4" fields). +//! - [`ResumeToken`] — the opaque gate-resume identity (was the `resume_token` +//! inside `approval_resume` / `auth_resume`); the loop echoes it back to resume +//! a gate. Only the *token* crosses — the raw input/estimate replay payload it +//! was bundled with stays host-side (charter: no raw input in vocabulary). +//! - [`LoopRef`] — the preserved *originating* loop ref (`result:*` / `gate:*` / +//! `process:*`), so the loop/evidence layer can still reach state it keyed under +//! its own ref after the kernel handle (a fresh uuid) is minted. +//! +//! ## Charter +//! +//! Every type here is **plain redacted vocabulary** (host_api charter): a bounded +//! enum, a fixed-width hash value, or a bounded validated safe identifier. None +//! carries a secret, a raw `HostPath`, a backend error string, or a runtime +//! handle. A [`LoopRef`] is a bounded correlation identifier with path delimiters +//! and control characters refused at construction — not free text, and distinct +//! from the kernel record refs ([`GateRef`](crate::GateRef) et al.), which stay +//! opaque uuids precisely so a caller cannot compose one from a string. + +use serde::{Deserialize, Serialize}; + +use crate::HostApiError; + +/// Stable digest over a capability's normalized output content — the host_api +/// mirror of `ironclaw_turns`' `ContentDigest` (a Blake3 keyed hash truncated to +/// 8 little-endian bytes). Pure metadata: a fixed-width hash value, never the +/// content itself, so it is safe on the sanitized boundary. Lets progress +/// detection compare outputs without retaining raw bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OutputDigest(u64); + +impl OutputDigest { + pub fn new(value: u64) -> Self { + Self(value) + } + + pub fn value(self) -> u64 { + self.0 + } +} + +/// Typed signal describing whether a completed capability advanced the loop's +/// evidence/state — the host_api mirror of `ironclaw_turns`' `CapabilityProgress`. +/// Lets the loop distinguish a deterministic no-change result from a productive +/// call without inferring progress from prose or token counts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResultProgress { + /// Older hosts, or hosts that cannot classify progress yet. + #[default] + Unknown, + /// Produced new evidence or changed host/runtime state. `complete` is an + /// accepted alias for wire compatibility with the loop enum. + #[serde(alias = "complete")] + MadeProgress, + /// Ran successfully but observed the same state/evidence as before. + NoChange, + /// Reached a deterministic non-suspending blocker. + Blocked, +} + +impl ResultProgress { + /// Stable discriminant (matches the serde tag) for logs/routing. + pub fn kind(&self) -> &'static str { + match self { + ResultProgress::Unknown => "unknown", + ResultProgress::MadeProgress => "made_progress", + ResultProgress::NoChange => "no_change", + ResultProgress::Blocked => "blocked", + } + } +} + +/// Host hint that a completed capability result should end the loop naturally +/// after the current batch — the host_api mirror of the loop's `terminate_hint` +/// bool, modeled as an enum so the two states are named rather than magic. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminateHint { + /// The loop should continue after this result (the default). + #[default] + Continue, + /// The loop should end naturally after the current batch. + TerminateAfterBatch, +} + +impl TerminateHint { + /// Build from the loop's boolean `terminate_hint`. + pub fn from_bool(terminate: bool) -> Self { + if terminate { + Self::TerminateAfterBatch + } else { + Self::Continue + } + } + + /// Whether the loop should end after the current batch. + pub fn should_terminate(&self) -> bool { + matches!(self, TerminateHint::TerminateAfterBatch) + } +} + +/// The recovery classification of a recoverable failure — the host_api mirror of +/// `ironclaw_turns`' `CapabilityFailureKind`. This is the class that drives +/// retry-vs-terminal handling; it is a bounded *taxonomy* (`network`, `backend`, +/// `authorization`, …), never a raw backend error string — the raw cause stays +/// host-side. An open `Unknown` escape hatch keeps a newer producer's unrecognized +/// tag representable (forward compatibility), mirroring the loop enum. +/// +/// Deliberately NOT `#[non_exhaustive]`: the `Unknown` variant is the open-set +/// escape hatch, and the manual `as_str`/`from_tag` route every value through it, +/// so downstream classifiers can match exhaustively (a new *named* variant fails +/// to compile until it is deliberately classified). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FailureKind { + Authorization, + Backend, + Cancelled, + Dispatcher, + GateDeclined, + InvalidInput, + InvalidOutput, + MissingRuntime, + Network, + OperationFailed, + OutputTooLarge, + PolicyDenied, + Process, + Resource, + Transient, + Unavailable, + Internal, + Permanent, + /// A tag outside the closed set above (forward compatibility). Bounded and + /// validated at construction; never a raw error string. + Unknown(FailureKindValue), +} + +/// A validated, bounded tag for [`FailureKind::Unknown`]. Safe-identifier +/// charset, so it can never carry a raw payload/path/secret. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FailureKindValue(String); + +impl FailureKindValue { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_safe_tag("failure_kind", &value, 128)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FailureKind { + /// Construct an open-set [`FailureKind::Unknown`] from a validated tag. + pub fn unknown(value: impl Into) -> Result { + FailureKindValue::new(value).map(Self::Unknown) + } + + /// The stable wire tag (matches the loop enum's `as_str`, so a value maps + /// losslessly across the two vocabularies). + pub fn as_str(&self) -> &str { + match self { + FailureKind::Authorization => "authorization", + FailureKind::Backend => "backend", + FailureKind::Cancelled => "cancelled", + FailureKind::Dispatcher => "dispatcher", + FailureKind::GateDeclined => "gate_declined", + FailureKind::InvalidInput => "invalid_input", + FailureKind::InvalidOutput => "invalid_output", + FailureKind::MissingRuntime => "missing_runtime", + FailureKind::Network => "network", + FailureKind::OperationFailed => "operation_failed", + FailureKind::OutputTooLarge => "output_too_large", + FailureKind::PolicyDenied => "policy_denied", + FailureKind::Process => "process", + FailureKind::Resource => "resource", + FailureKind::Transient => "transient", + FailureKind::Unavailable => "unavailable", + FailureKind::Internal => "internal", + FailureKind::Permanent => "permanent", + FailureKind::Unknown(value) => value.as_str(), + } + } + + /// Reconstruct a `FailureKind` from a wire tag. A known tag yields its named + /// variant; anything else buckets into a validated [`FailureKind::Unknown`]. + /// Total: an unvalidatable tag (never produced by the loop's own validator) + /// falls back to [`FailureKind::Internal`]. + pub fn from_tag(tag: &str) -> Self { + match tag { + "authorization" => Self::Authorization, + "backend" => Self::Backend, + "cancelled" => Self::Cancelled, + "dispatcher" => Self::Dispatcher, + "gate_declined" => Self::GateDeclined, + "invalid_input" => Self::InvalidInput, + "invalid_output" => Self::InvalidOutput, + "missing_runtime" => Self::MissingRuntime, + "network" => Self::Network, + "operation_failed" => Self::OperationFailed, + "output_too_large" => Self::OutputTooLarge, + "policy_denied" => Self::PolicyDenied, + "process" => Self::Process, + "resource" => Self::Resource, + "transient" => Self::Transient, + "unavailable" => Self::Unavailable, + "internal" => Self::Internal, + "permanent" => Self::Permanent, + other => Self::unknown(other).unwrap_or(Self::Internal), + } + } +} + +impl std::fmt::Display for FailureKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl Serialize for FailureKind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for FailureKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Borrow when the input allows it: the 18 named variants allocate + // nothing, and only an `Unknown` tag needs an owned copy. + let value = std::borrow::Cow::::deserialize(deserializer)?; + Ok(Self::from_tag(&value)) + } +} + +/// An opaque, redacted gate-resume identity — the host_api mirror of the loop's +/// `CapabilityResumeToken`. Produced when a gate is raised and echoed back by the +/// loop to resume it; the host reconstitutes the original execution context (input +/// replay, estimate, prior-approval lease) from its own storage keyed by this +/// token. Only the token crosses the boundary: bounded and control-free, it +/// carries identity, never the raw input/estimate it was bundled with. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ResumeToken(String); + +impl ResumeToken { + /// Maximum length in bytes — matches the loop's `CapabilityResumeToken` bound, + /// so any loop-minted token is representable losslessly. + pub const MAX_BYTES: usize = 128; + + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(HostApiError::invalid_id( + "resume_token", + value, + "must not be empty", + )); + } + if value.len() > Self::MAX_BYTES { + return Err(HostApiError::invalid_id( + "resume_token", + value, + format!("must be at most {} bytes", Self::MAX_BYTES), + )); + } + if value.chars().any(|c| c == '\0' || c.is_control()) { + return Err(HostApiError::invalid_id( + "resume_token", + "", + "must not contain NUL/control characters", + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ResumeToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Serialize for ResumeToken { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ResumeToken { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +/// The preserved *originating* loop ref (`result:*` / `gate:*` / `process:*`) a +/// kernel handle was minted for. The kernel record refs ([`GateRef`](crate::GateRef), +/// [`ResultRef`](crate::ResultRef), [`ProcessRef`](crate::ProcessRef)) are opaque +/// uuids by design, so they cannot carry the loop's own ref identity; without +/// this, state the loop keyed under its ref (e.g. output staged by the result +/// writer) becomes unreachable once the handle is minted. `LoopRef` carries that +/// originating ref alongside the kernel handle so it stays reachable through the +/// migration window. +/// +/// It is a **bounded, redacted correlation identifier**, not free text: control +/// characters and path delimiters (`/`, `\`, `..`) are refused at construction, so +/// it can hold no raw path — and it is a *distinct* type from the kernel refs, so +/// it can never be mistaken for one. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LoopRef(String); + +impl LoopRef { + /// Maximum length in bytes — matches the widest loop ref bound + /// (`LoopProcessRef`, 256), so any loop ref is representable losslessly. + pub const MAX_BYTES: usize = 256; + + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(HostApiError::invalid_id( + "loop_ref", + value, + "must not be empty", + )); + } + if value.len() > Self::MAX_BYTES { + return Err(HostApiError::invalid_id( + "loop_ref", + value, + format!("must be at most {} bytes", Self::MAX_BYTES), + )); + } + if value.chars().any(|c| c == '\0' || c.is_control()) { + return Err(HostApiError::invalid_id( + "loop_ref", + "", + "must not contain NUL/control characters", + )); + } + if value.contains('/') || value.contains('\\') || value.contains("..") { + return Err(HostApiError::invalid_id( + "loop_ref", + value, + "must not contain path separators or parent-directory markers", + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for LoopRef { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Serialize for LoopRef { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for LoopRef { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +/// Shared validator for bounded safe-identifier tags (the `FailureKind::Unknown` +/// tag): non-empty, bounded, and restricted to a safe identifier charset so no +/// raw payload/path/secret can ride along. +fn validate_safe_tag( + kind: &'static str, + value: &str, + max_bytes: usize, +) -> Result<(), HostApiError> { + if value.is_empty() { + return Err(HostApiError::invalid_id(kind, value, "must not be empty")); + } + if value.len() > max_bytes { + return Err(HostApiError::invalid_id( + kind, + value, + format!("must be at most {max_bytes} bytes"), + )); + } + if !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':')) + { + return Err(HostApiError::invalid_id( + kind, + value, + "must contain only ASCII letters, digits, _, -, ., or :", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_digest_is_transparent_on_the_wire() { + let digest = OutputDigest::new(0x0102_0304_0506_0708); + let json = serde_json::to_value(digest).unwrap(); + assert_eq!(json, serde_json::json!(0x0102_0304_0506_0708u64)); + assert_eq!( + serde_json::from_value::(json) + .unwrap() + .value(), + 0x0102_0304_0506_0708 + ); + } + + #[test] + fn result_progress_snake_case_and_complete_alias() { + for (progress, tag) in [ + (ResultProgress::Unknown, "unknown"), + (ResultProgress::MadeProgress, "made_progress"), + (ResultProgress::NoChange, "no_change"), + (ResultProgress::Blocked, "blocked"), + ] { + assert_eq!( + serde_json::to_value(progress).unwrap(), + serde_json::Value::String(tag.to_string()) + ); + assert_eq!(progress.kind(), tag); + } + assert_eq!(ResultProgress::default(), ResultProgress::Unknown); + // The loop enum's `complete` alias still decodes (wire compatibility). + assert_eq!( + serde_json::from_value::(serde_json::json!("complete")).unwrap(), + ResultProgress::MadeProgress + ); + } + + #[test] + fn terminate_hint_bool_bridge_and_wire() { + assert_eq!( + TerminateHint::from_bool(true), + TerminateHint::TerminateAfterBatch + ); + assert_eq!(TerminateHint::from_bool(false), TerminateHint::Continue); + assert!(TerminateHint::TerminateAfterBatch.should_terminate()); + assert!(!TerminateHint::Continue.should_terminate()); + assert_eq!(TerminateHint::default(), TerminateHint::Continue); + assert_eq!( + serde_json::to_value(TerminateHint::TerminateAfterBatch).unwrap(), + serde_json::Value::String("terminate_after_batch".to_string()) + ); + } + + #[test] + fn failure_kind_tags_round_trip_for_every_named_variant() { + let named = [ + FailureKind::Authorization, + FailureKind::Backend, + FailureKind::Cancelled, + FailureKind::Dispatcher, + FailureKind::GateDeclined, + FailureKind::InvalidInput, + FailureKind::InvalidOutput, + FailureKind::MissingRuntime, + FailureKind::Network, + FailureKind::OperationFailed, + FailureKind::OutputTooLarge, + FailureKind::PolicyDenied, + FailureKind::Process, + FailureKind::Resource, + FailureKind::Transient, + FailureKind::Unavailable, + FailureKind::Internal, + FailureKind::Permanent, + ]; + for kind in named { + let tag = kind.as_str(); + assert_eq!( + FailureKind::from_tag(tag), + kind, + "from_tag round-trip: {tag}" + ); + let wire = serde_json::to_value(&kind).unwrap(); + assert_eq!(wire, serde_json::Value::String(tag.to_string())); + assert_eq!(serde_json::from_value::(wire).unwrap(), kind); + } + } + + #[test] + fn failure_kind_unknown_tag_is_preserved_not_dropped() { + // A newer producer's tag survives round-trip through Unknown. + let kind = FailureKind::from_tag("quota_exceeded"); + assert_eq!(kind, FailureKind::unknown("quota_exceeded").unwrap()); + assert_eq!(kind.as_str(), "quota_exceeded"); + let back: FailureKind = + serde_json::from_value(serde_json::to_value(&kind).unwrap()).unwrap(); + assert_eq!(back, kind); + } + + #[test] + fn resume_token_bounded_control_free_and_round_trips() { + let token = ResumeToken::new("resume-abc.123").unwrap(); + assert_eq!(token.as_str(), "resume-abc.123"); + let back: ResumeToken = + serde_json::from_value(serde_json::to_value(&token).unwrap()).unwrap(); + assert_eq!(back, token); + assert!(ResumeToken::new("").is_err()); + assert!(ResumeToken::new("has\nnewline").is_err()); + assert!(ResumeToken::new("x".repeat(ResumeToken::MAX_BYTES + 1)).is_err()); + } + + #[test] + fn loop_ref_preserves_the_loop_charset_but_refuses_paths() { + for value in ["result:child-1", "gate:approval-req_9", "process:pid-1"] { + let loop_ref = LoopRef::new(value).unwrap(); + assert_eq!(loop_ref.as_str(), value); + let back: LoopRef = + serde_json::from_value(serde_json::to_value(&loop_ref).unwrap()).unwrap(); + assert_eq!(back, loop_ref); + } + // Charter: no raw paths / control chars. + assert!(LoopRef::new("result:/etc/passwd").is_err()); + assert!(LoopRef::new("result:..\\escape").is_err()); + assert!(LoopRef::new("result:a\0b").is_err()); + assert!(LoopRef::new("").is_err()); + } +} diff --git a/crates/ironclaw_host_api/tests/authorized_seal.rs b/crates/ironclaw_host_api/tests/authorized_seal.rs index 4f819958011..d8124a9e58c 100644 --- a/crates/ironclaw_host_api/tests/authorized_seal.rs +++ b/crates/ironclaw_host_api/tests/authorized_seal.rs @@ -8,7 +8,7 @@ use ironclaw_host_api::{ ActivityId, AuthorizeResult, Authorized, Blocked, CapabilityAuthorizer, CapabilityId, DenyRef, - GateRef, Invocation, InvocationOrigin, MountView, ProductKind, ResourceEstimate, + GateRef, GateWaypoint, Invocation, InvocationOrigin, MountView, ProductKind, ResourceEstimate, ResourceReservation, ResourceReservationId, ResourceScope, RuntimeLane, Timestamp, UserId, }; @@ -107,7 +107,7 @@ fn authorize_result_kinds() { ); assert_eq!(AuthorizeResult::Denied(DenyRef::new()).kind(), "denied"); assert_eq!( - AuthorizeResult::Blocked(Blocked::Auth(GateRef::new())).kind(), + AuthorizeResult::Blocked(Blocked::Auth(GateWaypoint::new(GateRef::new()))).kind(), "blocked" ); } diff --git a/crates/ironclaw_run_state/tests/gate_record_store_contract.rs b/crates/ironclaw_run_state/tests/gate_record_store_contract.rs index be9b1713aa2..e32fe86e904 100644 --- a/crates/ironclaw_run_state/tests/gate_record_store_contract.rs +++ b/crates/ironclaw_run_state/tests/gate_record_store_contract.rs @@ -200,6 +200,7 @@ fn every_gate_record_variant() -> Vec { summary: summary(), result, byte_len: 2048, + result_origin: Some(LoopRef::new("result:child-1").unwrap()), }, GateRecord::ExternalTool { summary: summary() }, ] diff --git a/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs b/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs index ffea4ae8523..aab5440bb4a 100644 --- a/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs +++ b/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs @@ -14,50 +14,54 @@ //! later producer/consumer migration wires this in. `CapabilityOutcome` is //! unchanged and every existing path keeps its current behavior. //! -//! ## What crosses, and what is dropped +//! ## Non-lossy carry (§5.3 Stage 1) //! -//! The mapping is the definition of done in §5.3's acceptance table. Two classes -//! of field on the old variants have **no home** on the new channels and are -//! deliberately dropped here (documented per the G-decisions in the doc): +//! `host_api::Resolution` now carries **every recoverable field** the old +//! `CapabilityOutcome` variants held, via the vocabulary in +//! [`ironclaw_host_api::result_meta`]: //! -//! - **G1 — the failure recovery class does not cross.** `CapabilityFailure`'s -//! `error_kind` ([`CapabilityFailureKind`]) and `detail` are host-side recovery -//! classification; [`Outcome`] carries only a typed [`ToolVerdict`] plus the -//! redacted summary, so a `Failed` maps to a plain -//! [`ToolVerdict::RecoverableFailure`] and its kind/detail are not propagated. -//! - **G4 — loop-derived signals do not cross.** `CapabilityResultMessage`'s -//! `progress`, `terminate_hint`, and `output_digest` are loop-derived (the loop -//! computes them); they are not part of the host's `Outcome` and are dropped. +//! - `CapabilityFailure::error_kind` ([`CapabilityFailureKind`]) → the +//! [`FailureKind`] on [`ToolVerdict::RecoverableFailure`] — the recovery class +//! that drives retry-vs-terminal now crosses (was "G1-dropped"). Only the raw +//! `detail` stays host-side (a backend cause, not vocabulary — charter). +//! - `CapabilityResultMessage::{progress, terminate_hint, output_digest}` → +//! [`Outcome::progress`]/[`Outcome::terminate_hint`]/[`OutcomeRefs::output_digest`] +//! (were the "G4-dropped" loop-derived signals). +//! - The `resume_token` inside `approval_resume`/`auth_resume` → the +//! [`ResumeToken`] on the gate [`GateWaypoint`], so the loop can echo it back to +//! resume the gate. Only the *token* crosses; the raw input/estimate replay it +//! was bundled with stays host-side (charter: no raw input in vocabulary — the +//! host reconstitutes it from storage keyed by the token). //! -//! `SpawnedProcess`'s `safe_summary` is also dropped: a -//! [`Suspension::Process`] carries only a [`ProcessRef`] — host_api has no process -//! record type for a summary to land on. `AwaitDependentRun`'s `model_observation` -//! is dropped: [`GateRecord::DependentRun`] carries a summary + staged result, not -//! a preview. +//! `SpawnedProcess`'s `safe_summary` still has no host channel (a process +//! suspension carries a [`ProcessRef`], not a summary). `AwaitDependentRun`'s and +//! `SpawnedChildRun`'s `model_observation` ride the result preview where present. //! -//! ## String refs → uuid refs +//! ## Loop refs: minted kernel handle + preserved origin //! -//! The loop's refs ([`LoopResultRef`], [`LoopGateRef`], [`LoopProcessRef`], -//! [`TurnRunId`]) are opaque prefixed strings; host_api's refs -//! ([`ResultRef`]/[`GateRef`]/[`DenyRef`]/[`ProcessRef`]) are uuids. This pure -//! mapping **mints a fresh uuid ref** for each host-side handle — it cannot -//! reconstruct a meaningful uuid from an opaque loop string. Every minted ref is -//! returned **bound to its loop-side source** in [`RefBindings`], so the later -//! wiring slice can persist the loop-ref↔uuid-ref association at the -//! writer/store boundary — already-stored loop state (e.g. output the result -//! writer stored under the loop ref) stays reachable instead of being stranded -//! behind an unbound uuid. The only identity that crosses directly is -//! [`TurnRunId`] → [`RunId`]: both wrap a `Uuid`, preserved via -//! `RunId::from_uuid`. +//! The loop's refs ([`LoopResultRef`], [`LoopGateRef`], [`LoopProcessRef`]) are +//! opaque prefixed strings (`result:*`/`gate:*`/`process:*`); host_api's kernel +//! refs ([`ResultRef`]/[`GateRef`]/[`ProcessRef`]) are opaque uuids by design, so +//! they cannot carry the loop's own ref identity. The mapping mints a fresh kernel +//! handle **and** preserves the originating loop ref on the channel's `origin` +//! (a [`LoopRef`]) — so loop/evidence state keyed under the loop ref (e.g. output +//! the result writer staged) stays reachable through the migration window, not only +//! via the [`RefBindings`] side-table (which is retained). The only identity that +//! crosses directly is [`TurnRunId`](crate::TurnRunId) → [`RunId`]: both wrap a +//! `Uuid`, preserved via `RunId::from_uuid`. use ironclaw_host_api::{ - Blocked, DenyReason, DenyRecord, DenyRef, GateRecord, GateRef, Outcome, OutcomeRefs, - ProcessRef, Resolution, ResultRef, RunId, SafeSummary, Suspension, ToolVerdict, + Blocked, DenyReason, DenyRecord, DenyRef, FailureKind, GateRecord, GateRef, GateWaypoint, + LoopRef, Outcome, OutcomeRefs, OutputDigest, ProcessRef, ProcessWaypoint, Resolution, + ResultProgress, ResultRef, ResumeToken, RunId, SafeSummary, Suspension, TerminateHint, + ToolVerdict, }; +use super::content_digest::ContentDigest; use super::host::{ - CapabilityDenied, CapabilityDeniedReasonKind, CapabilityFailure, CapabilityOutcome, - CapabilityResultMessage, LoopProcessRef, ProcessHandleSummary, + CapabilityApprovalResume, CapabilityAuthResume, CapabilityDenied, CapabilityDeniedReasonKind, + CapabilityFailure, CapabilityFailureKind, CapabilityOutcome, CapabilityProgress, + CapabilityResultMessage, CapabilityResumeToken, LoopProcessRef, ProcessHandleSummary, }; use super::model_observation::ModelVisibleToolObservation; use crate::{LoopGateRef, LoopResultRef}; @@ -161,15 +165,16 @@ impl MappedResolution { pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedResolution { match outcome { // Ran and succeeded. Loop-derived progress/terminate_hint/output_digest - // (G4) are dropped; a fresh ResultRef is minted and bound to the loop - // result_ref so the stored output stays reachable. + // now cross onto the Outcome; a fresh ResultRef handle is minted, the + // loop result_ref is preserved on OutcomeRefs.origin AND bound so the + // stored output stays reachable. CapabilityOutcome::Completed(message) => { let (outcome, loop_result) = completed_outcome(message); let minted = outcome.refs.result; MappedResolution::bare(Resolution::Done(outcome)).bind_result(loop_result, minted) } // Ran and failed in a model-visible, correctable way. The recovery class - // (error_kind) and detail (G1) are host-side and do not cross. + // (error_kind) rides the verdict; only the raw detail stays host-side. CapabilityOutcome::Failed(failure) => { MappedResolution::bare(Resolution::Done(failed_outcome(failure))) } @@ -187,17 +192,19 @@ pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedRes }, ) } - // Re-entrant gate: needs human approval before it may run. + // Re-entrant gate: needs human approval before it may run. The gate-render + // content (summary) rides the GateRecord; the resume token and the + // preserved loop gate ref ride the waypoint (never the model-visible + // record — §5.2.9). CapabilityOutcome::ApprovalRequired { gate_ref, safe_summary, - // approval_resume is loop/host resume identity, not gate-render - // content; it does not cross into the GateRecord. - .. + approval_resume, } => { let minted = GateRef::new(); + let waypoint = gate_waypoint(minted, &gate_ref, approval_resume_token(approval_resume)); MappedResolution::with_gate( - Resolution::Blocked(Blocked::Approval(minted)), + Resolution::Blocked(Blocked::Approval(waypoint)), GateRecord::Approval { summary: safe_summary_or_placeholder(safe_summary), }, @@ -205,17 +212,18 @@ pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedRes .bind_gate(gate_ref, minted) } // Re-entrant gate: needs a credential the caller has not supplied. The - // host-owned credential requirements ride the record (G3). + // host-owned credential requirements ride the record (G3); the resume + // token and preserved loop gate ref ride the waypoint. CapabilityOutcome::AuthRequired { gate_ref, credential_requirements, safe_summary, - // auth_resume is loop/host resume identity, not gate-render content. - .. + auth_resume, } => { let minted = GateRef::new(); + let waypoint = gate_waypoint(minted, &gate_ref, auth_resume_token(auth_resume)); MappedResolution::with_gate( - Resolution::Blocked(Blocked::Auth(minted)), + Resolution::Blocked(Blocked::Auth(waypoint)), GateRecord::Auth { summary: safe_summary_or_placeholder(safe_summary), credential_requirements, @@ -223,14 +231,16 @@ pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedRes ) .bind_gate(gate_ref, minted) } - // Re-entrant gate: needs resource budget currently unavailable. + // Re-entrant gate: needs resource budget currently unavailable. No resume + // token — a resource gate resumes against then-current budget (§5.3.3). CapabilityOutcome::ResourceBlocked { gate_ref, safe_summary, } => { let minted = GateRef::new(); + let waypoint = gate_waypoint(minted, &gate_ref, None); MappedResolution::with_gate( - Resolution::Blocked(Blocked::Resource(minted)), + Resolution::Blocked(Blocked::Resource(waypoint)), GateRecord::Resource { summary: safe_summary_or_placeholder(safe_summary), }, @@ -238,11 +248,13 @@ pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedRes .bind_gate(gate_ref, minted) } // Parked work: a spawned OS process the turn now waits on. Process - // suspensions track a ProcessRef, not a gate record; the loop summary has - // no home on the host channel and is dropped. + // suspensions track a ProcessRef, not a gate record; the loop process ref + // is preserved on the waypoint origin (the loop summary still has no host + // channel). CapabilityOutcome::SpawnedProcess(ProcessHandleSummary { process_ref, .. }) => { let minted = ProcessRef::new(); - MappedResolution::bare(Resolution::Suspended(Suspension::Process(minted))) + let waypoint = process_waypoint(minted, &process_ref); + MappedResolution::bare(Resolution::Suspended(Suspension::Process(waypoint))) .bind_process(process_ref, minted) } // NON-suspending (the #6137 bug class): the executor appends the child @@ -262,11 +274,15 @@ pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedRes result: minted, byte_len, preview: observation_preview(model_observation), + origin: preserved_origin(result_ref.as_str()), + output_digest: None, }, verdict: ToolVerdict::ChildSpawned { child_run: RunId::from_uuid(child_run_id.as_uuid()), }, summary: safe_summary_or_placeholder(safe_summary), + progress: ResultProgress::default(), + terminate_hint: TerminateHint::default(), })) .bind_result(result_ref, minted) } @@ -283,12 +299,14 @@ pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedRes } => { let minted_gate = GateRef::new(); let minted_result = ResultRef::new(); + let waypoint = gate_waypoint(minted_gate, &gate_ref, None); MappedResolution::with_gate( - Resolution::Suspended(Suspension::DependentRun(minted_gate)), + Resolution::Suspended(Suspension::DependentRun(waypoint)), GateRecord::DependentRun { summary: safe_summary_or_placeholder(safe_summary), result: minted_result, byte_len, + result_origin: preserved_origin(result_ref.as_str()), }, ) .bind_gate(gate_ref, minted_gate) @@ -300,8 +318,9 @@ pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedRes safe_summary, } => { let minted = GateRef::new(); + let waypoint = gate_waypoint(minted, &gate_ref, None); MappedResolution::with_gate( - Resolution::Suspended(Suspension::ExternalTool(minted)), + Resolution::Suspended(Suspension::ExternalTool(waypoint)), GateRecord::ExternalTool { summary: safe_summary_or_placeholder(safe_summary), }, @@ -320,43 +339,134 @@ fn completed_outcome(message: CapabilityResultMessage) -> (Outcome, LoopResultRe safe_summary, byte_len, model_observation, - // G4: progress / terminate_hint / output_digest are loop-derived and have - // no home on the host Outcome. - .. + progress, + terminate_hint, + output_digest, } = message; let outcome = Outcome { refs: OutcomeRefs { result: ResultRef::new(), byte_len, preview: observation_preview(model_observation), + origin: preserved_origin(result_ref.as_str()), + output_digest: output_digest.map(output_digest_of), }, verdict: ToolVerdict::Success, summary: safe_summary_or_placeholder(safe_summary), + progress: result_progress_of(progress), + terminate_hint: TerminateHint::from_bool(terminate_hint), }; (outcome, result_ref) } -/// Build the `Done` payload for a `Failed` outcome (verdict `RecoverableFailure`). +/// Build the `Done` payload for a `Failed` outcome (verdict `RecoverableFailure`), +/// carrying the recovery classification on the verdict. Only the raw `detail` +/// (a backend cause) stays host-side — not authority vocabulary (charter). fn failed_outcome(failure: CapabilityFailure) -> Outcome { let CapabilityFailure { + error_kind, safe_summary, - // G1: error_kind (the recovery class) and detail are host-side and do not - // cross into the model-visible Outcome. - .. + detail: _, } = failure; Outcome { refs: OutcomeRefs { // A recoverable failure stages no durable output beyond its summary; - // the ref is a minted handle the later store may leave unpopulated. + // the ref is a minted handle the later store may leave unpopulated, + // and there is no originating loop result ref to preserve. result: ResultRef::new(), byte_len: 0, preview: None, + origin: None, + output_digest: None, + }, + verdict: ToolVerdict::RecoverableFailure { + error_kind: failure_kind_of(error_kind), }, - verdict: ToolVerdict::RecoverableFailure, summary: safe_summary_or_placeholder(safe_summary), + progress: ResultProgress::default(), + terminate_hint: TerminateHint::default(), + } +} + +/// A gate waypoint: the minted kernel handle plus the preserved originating loop +/// gate ref and (for approval/auth) the opaque resume token the loop echoes back. +fn gate_waypoint( + minted: GateRef, + loop_gate: &LoopGateRef, + resume: Option, +) -> GateWaypoint { + let mut waypoint = GateWaypoint::new(minted); + if let Some(origin) = preserved_origin(loop_gate.as_str()) { + waypoint = waypoint.with_origin(origin); + } + if let Some(resume) = resume { + waypoint = waypoint.with_resume(resume); + } + waypoint +} + +/// A process waypoint: the minted kernel handle plus the preserved originating +/// loop process ref. +fn process_waypoint(minted: ProcessRef, loop_process: &LoopProcessRef) -> ProcessWaypoint { + match preserved_origin(loop_process.as_str()) { + Some(origin) => ProcessWaypoint::new(minted).with_origin(origin), + None => ProcessWaypoint::new(minted), + } +} + +/// Preserve a loop ref as a redacted host_api [`LoopRef`] when it satisfies the +/// host redaction contract (bounded, control-free, no path delimiters). A loop +/// ref that fails — which a safe production ref never does — falls back to `None` +/// and stays reachable only through [`RefBindings`]; `.ok()` here converts a pure +/// text-to-safe-text validation failure into an absent origin, never a swallowed +/// I/O error. +fn preserved_origin(loop_ref: &str) -> Option { + LoopRef::new(loop_ref).ok() +} + +/// The opaque approval resume token, when the outcome carried one. +fn approval_resume_token(resume: Option) -> Option { + resume.and_then(|resume| resume_token_of(&resume.resume_token)) +} + +/// The opaque auth resume token, when the outcome carried one. +fn auth_resume_token(resume: Option) -> Option { + resume.and_then(|resume| resume_token_of(&resume.resume_token)) +} + +/// Convert a loop-facing [`CapabilityResumeToken`] to a host_api [`ResumeToken`]. +/// Both are bounded/control-free, so a valid loop token always crosses; `.ok()` +/// drops a token that fails the host bound rather than panic (the mapping is +/// total) — such a token, never produced by the loop's own validator, then +/// resumes through the retained binding. +fn resume_token_of(token: &CapabilityResumeToken) -> Option { + ResumeToken::new(token.as_str()).ok() +} + +/// Map the loop's [`ContentDigest`] onto host_api's [`OutputDigest`]; both wrap +/// the same truncated Blake3 `u64`. +fn output_digest_of(digest: ContentDigest) -> OutputDigest { + OutputDigest::new(digest.0) +} + +/// Map the loop's [`CapabilityProgress`] onto host_api's [`ResultProgress`]; the +/// variants correspond one-to-one. +fn result_progress_of(progress: CapabilityProgress) -> ResultProgress { + match progress { + CapabilityProgress::Unknown => ResultProgress::Unknown, + CapabilityProgress::MadeProgress => ResultProgress::MadeProgress, + CapabilityProgress::NoChange => ResultProgress::NoChange, + CapabilityProgress::Blocked => ResultProgress::Blocked, } } +/// Map the loop's [`CapabilityFailureKind`] onto host_api's [`FailureKind`] by its +/// stable tag — the two vocabularies share the same closed set plus an open +/// `Unknown`, so every value crosses losslessly. +fn failure_kind_of(kind: CapabilityFailureKind) -> FailureKind { + FailureKind::from_tag(kind.as_str()) +} + /// Bounded model-visible preview from a loop tool observation, when present. /// /// The observation's `summary` is model-visible text; it is re-validated through @@ -404,6 +514,7 @@ fn deny_reason_from_kind(kind: &CapabilityDeniedReasonKind) -> DenyReason { #[cfg(test)] mod tests { + use super::super::host::CapabilityInputRef; use super::super::{ CapabilityFailureKind, CapabilityProgress, LoopProcessRef, MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, @@ -411,7 +522,8 @@ mod tests { use super::*; use crate::{LoopGateRef, LoopResultRef, TurnRunId}; use ironclaw_host_api::{ - ExtensionId, RuntimeCredentialAccountProviderId, RuntimeCredentialAccountSetup, + ApprovalRequestId, CorrelationId, ExtensionId, ResourceEstimate, + RuntimeCredentialAccountProviderId, RuntimeCredentialAccountSetup, RuntimeCredentialAuthRequirement, }; @@ -735,6 +847,160 @@ mod tests { } } + /// Stage-1 non-lossy: a `Completed` outcome's loop-derived G4 signals + /// (progress, terminate_hint, output_digest) and its originating loop result + /// ref now survive the mapping into `Resolution::Done` instead of being + /// dropped. + #[test] + fn completed_carries_progress_terminate_hint_digest_and_origin() { + let digest = + ContentDigest::from_json_value(&serde_json::json!({"k": "v"})).expect("digest"); + let outcome = CapabilityOutcome::Completed(CapabilityResultMessage { + result_ref: result_ref(), + safe_summary: "did work".to_string(), + progress: CapabilityProgress::MadeProgress, + terminate_hint: true, + byte_len: 4096, + output_digest: Some(digest), + model_observation: None, + }); + let mapped = capability_outcome_to_resolution(outcome); + match mapped.resolution { + Resolution::Done(done) => { + assert_eq!(done.progress, ResultProgress::MadeProgress); + assert!(done.terminate_hint.should_terminate()); + assert_eq!( + done.refs.output_digest.map(OutputDigest::value), + Some(digest.0), + "output_digest must survive the mapping (was G4-dropped)" + ); + assert_eq!( + done.refs.origin.as_ref().map(LoopRef::as_str), + Some(result_ref().as_str()), + "the originating loop result ref must be preserved on OutcomeRefs.origin" + ); + } + other => panic!("expected Done, got {other:?}"), + } + } + + /// Stage-1 non-lossy: a `Failed` outcome's recovery classification + /// (error_kind, the "G1" field) now rides `ToolVerdict::RecoverableFailure` + /// instead of being dropped. + #[test] + fn failed_carries_its_error_kind_on_the_verdict() { + for (loop_kind, expected) in [ + (CapabilityFailureKind::Network, FailureKind::Network), + ( + CapabilityFailureKind::InvalidInput, + FailureKind::InvalidInput, + ), + ( + CapabilityFailureKind::unknown("quota_exceeded").unwrap(), + FailureKind::unknown("quota_exceeded").unwrap(), + ), + ] { + let mapped = + capability_outcome_to_resolution(CapabilityOutcome::Failed(CapabilityFailure { + error_kind: loop_kind, + safe_summary: "tool failed".to_string(), + detail: None, + })); + match mapped.resolution { + Resolution::Done(done) => { + assert_eq!( + done.verdict, + ToolVerdict::RecoverableFailure { + error_kind: expected.clone() + }, + "the recovery class must ride the verdict (was G1-dropped)" + ); + } + other => panic!("expected Done, got {other:?}"), + } + } + } + + /// Stage-1 non-lossy: an approval gate carries its resume token and preserved + /// loop gate ref (was G1-dropped), and an auth gate likewise. + #[test] + fn approval_and_auth_gates_carry_resume_token_and_preserved_origin() { + let approval_resume = CapabilityApprovalResume { + approval_request_id: ApprovalRequestId::new(), + resume_token: CapabilityResumeToken::new("approval-resume-1").unwrap(), + correlation_id: CorrelationId::new(), + input_ref: CapabilityInputRef::new("input:x").unwrap(), + input: serde_json::json!({"k": "v"}), + estimate: ResourceEstimate::default(), + }; + let mapped = capability_outcome_to_resolution(CapabilityOutcome::ApprovalRequired { + gate_ref: gate_ref(), + safe_summary: "awaiting approval".to_string(), + approval_resume: Some(approval_resume), + }); + match &mapped.resolution { + Resolution::Blocked(blocked @ Blocked::Approval(_)) => { + assert_eq!( + blocked.resume_token().map(ResumeToken::as_str), + Some("approval-resume-1"), + "the approval resume token must cross" + ); + assert_eq!( + blocked.origin().map(LoopRef::as_str), + Some(gate_ref().as_str()), + "the originating loop gate ref must be preserved" + ); + } + other => panic!("expected Blocked::Approval, got {other:?}"), + } + + let auth_resume = CapabilityAuthResume { + resume_token: CapabilityResumeToken::new("auth-resume-1").unwrap(), + prior_approval: None, + replay: None, + }; + let mapped = capability_outcome_to_resolution(CapabilityOutcome::AuthRequired { + gate_ref: gate_ref(), + credential_requirements: vec![], + safe_summary: "awaiting credential".to_string(), + auth_resume: Some(auth_resume), + }); + match &mapped.resolution { + Resolution::Blocked(blocked @ Blocked::Auth(_)) => { + assert_eq!( + blocked.resume_token().map(ResumeToken::as_str), + Some("auth-resume-1") + ); + assert_eq!( + blocked.origin().map(LoopRef::as_str), + Some(gate_ref().as_str()) + ); + } + other => panic!("expected Blocked::Auth, got {other:?}"), + } + } + + /// Stage-1 non-lossy: a spawned-process suspension preserves its loop process + /// ref on the channel (not only in the binding side-table). + #[test] + fn spawned_process_preserves_the_loop_process_ref_on_the_channel() { + let mapped = capability_outcome_to_resolution(CapabilityOutcome::SpawnedProcess( + ProcessHandleSummary { + process_ref: LoopProcessRef::new("process:pid-7").unwrap(), + safe_summary: "spawned".to_string(), + }, + )); + match &mapped.resolution { + Resolution::Suspended(suspension @ Suspension::Process(_)) => { + assert_eq!( + suspension.origin().map(LoopRef::as_str), + Some("process:pid-7") + ); + } + other => panic!("expected Suspended(Process), got {other:?}"), + } + } + #[test] fn child_run_identity_is_preserved_on_the_verdict() { let child_run_id = TurnRunId::new(); @@ -793,10 +1059,24 @@ mod tests { }); match mapped.gate_record { Some(GateRecord::DependentRun { - byte_len, summary, .. + byte_len, + summary, + result_origin, + .. }) => { assert_eq!(byte_len, 2048); assert_eq!(summary.as_str(), "awaiting dependent"); + // Stage-1 non-lossy: the staged result's originating loop ref + // is preserved ON THE DURABLE RECORD — the minted ResultRef is + // a fresh uuid, and the transient RefBindings side-table is not + // persisted, so without this the child output the loop staged + // under its own ref would be unreachable from the record a + // later resume turn renders from. + assert_eq!( + result_origin.as_ref().map(LoopRef::as_str), + Some(result_ref().as_str()), + "the staged result's loop origin must ride the durable record" + ); } other => panic!("expected GateRecord::DependentRun, got {other:?}"), } @@ -858,7 +1138,10 @@ mod tests { Resolution::Suspended(Suspension::DependentRun(channel_gate)), Some(GateRecord::DependentRun { result, .. }), ) => { - assert_eq!(*channel_gate, minted_gate, "gate binding matches channel"); + assert_eq!( + channel_gate.gate, minted_gate, + "gate binding matches channel" + ); assert_eq!(*result, minted_result, "result binding matches record"); } other => panic!("expected DependentRun channel + record, got {other:?}"), @@ -884,7 +1167,7 @@ mod tests { assert_eq!(loop_process, LoopProcessRef::new("process:pid-1").unwrap()); match &mapped.resolution { Resolution::Suspended(Suspension::Process(channel_process)) => { - assert_eq!(*channel_process, minted_process); + assert_eq!(channel_process.process, minted_process); } other => panic!("expected Suspended(Process), got {other:?}"), }